Using StackPort with MiniStack
MiniStack is an open-source AWS emulator that serves the whole API surface on a single port, 4566. StackPort gives it a web console: a dashboard of every service with live resource counts, and dedicated views for S3, DynamoDB, Lambda, SQS and more.
StackPort talks to MiniStack over the standard AWS API, the same way the AWS CLI does. Nothing needs to be installed or enabled inside MiniStack.
3 points
PS5 Nahi Hai, Phir Bhi Game Chalta Hai: 6,270 Stars | 5 Emulator GitHub Repos #Shorts
Top 5 trending GitHub repos, Hindi mein - har number aaj check kiya gaya hai.
Is week ke top trending repos, 18k+ stars combined:
1. sharpemu/sharpemu (6k stars) - An experimental PlayStation 5 emulator for Windows, Linux and macOS.
2. ministackorg/ministack (5k stars) - Ministack: Free, open-source local AWS emulator - 60+ services, Terraform compatible, real databases. Free forever. MIT licensed.
3. davidmonterocrespo24/velxio (3k stars) - Emulate Arduino, ESP32 & Raspberry Pi. in your browser. Write code, compile, and run on 19 real boards — Arduino Uno, ESP32, ESP32-C3, Raspberry Pi Pico, Raspberry Pi 3, and more. No hardware, no cloud, no limits.. Discord: discord.gg
4. magnum6actual/flipoff (3k stars) - Free split-flap display emulator for any TV. The classic flip-board look, without the $3,500 hardware.
5. snuri00/ps2-web-recomp (838 stars) - PS2 games in the browser: static recompilation to WebAssembly with a native runtime
Repo links:
sharpemu/sharpemu
ministackorg/ministack
davidmonterocrespo24/velxio
magnum6actual/flipoff
snuri00/ps2-web-recomp
Comment karo "RUN" - saare links DM mein bhej dunga.
Kisi dev dost ko bhejo jise ye chahiye.
Save karlo, baad mein kaam aayega.
Naman AI - AI aur dev tools, Hindi mein. Har din naya repo, har number verified.
#Shorts #GitHub #AI #OpenSource #TechHindi
[github trending repos, best github repositories 2026, top 5 github repos, open source projects for beginners, ai coding agents, github stars]
[coding in hindi, tech shorts hindi, programming for beginners, developer tools 2026, ai tools for developers, learn coding free]
[bn] PS5 নেই, তাও গেম চলে: ৬,২৭০ স্টারস | ৫টি এমুলেটর গিটহাব রিপো #Shorts
হিন্দিতে শীর্ষ ৫টি ট্রেন্ডিং গিটহাব রিপো - আজ প্রতিটি নম্বর চেক করা হয়েছে।
এই সপ্তাহের শীর্ষ ট্রেন্ডিং রিপো, সম্মিলিতভাবে ১৮ হাজারেরও বেশি স্টার:
১. sharpemu/sharpemu (৬ হাজার স্টার) - উইন্ডোজ, লিনাক্স এবং ম্যাকওএস-এর জন্য একটি পরীক্ষামূলক প্লেস্টেশন ৫ এমুলেটর।
২. ministackorg/ministack (৫ হাজার স্টার) - মিনিস্ট্যাক: ফ্রি, ওপেন-সোর্স লোকাল AWS এমুলেটর - ৬০টিরও বেশি সার্ভিস, টেরাফর্ম কম্প্যাটিবল, আসল ডেটাবেস। আজীবন বিনামূল্যে। MIT লাইসেন্সপ্রাপ্ত।
৩. davidmonterocrespo24/velxio (৩ হাজার স্টার) - আপনার ব্রাউজারে আরডুইনো, ESP32 এবং রাস্পবেরি পাই এমুলেট করুন। ১৯টি আসল বোর্ডে কোড লিখুন, কম্পাইল করুন এবং চালান — আরডুইনো উনো, ESP32, ESP32-C3, রাস্পবেরি পাই পিকো, রাস্পবেরি পাই ৩, এবং আরও অনেক কিছু। কোনো হার্ডওয়্যার নেই, কোনো ক্লাউড নেই, কোনো সীমাবদ্ধতা নেই.. ডিসকর্ড: discord.gg
৪. magnum6actual/flipoff (৩ হাজার স্টার) - যেকোনো টিভির জন্য ফ্রি স্প্লিট-ফ্ল্যাপ ডিসপ্লে এমুলেটর। ৩,৫০০ ডলারের হার্ডওয়্যার ছাড়াই ক্লাসিক ফ্লিপ-বোর্ড লুক।
৫. snuri00/ps2-web-recomp (৮৩৮ স্টার) - ব্রাউজারে PS2 গেম: একটি নেটিভ রানটাইম সহ WebAssembly-তে স্ট্যাটিক রিকম্পাইলেশন।
রেপো লিঙ্ক:
sharpemu/sharpemu
ministackorg/ministack
davidmonterocrespo24/velxio
magnum6actual/flipoff
snuri00/ps2-web-recomp
"RUN" কমেন্ট করুন - সব লিঙ্ক DM-এ পাঠিয়ে দেব।
কিসি দেব দোস্ত কো ভেজো জিসে ইয়ে চাহিয়ে।
করলো বাঁচাও, বাদ মে কাম আয়েগা।
নমন এআই - এআই অর দেব টুল, হিন্দি মে। হার দিন নয়া রেপো, হার নম্বর যাচাই করা হয়েছে।
#শর্টস #GitHub #AI #OpenSource #TechHindi
[গিথুব ট্রেন্ডিং রিপোজ, সেরা গিথুব রিপোজিটরি 2026, শীর্ষ 5 গিথুব রিপোজ, নতুনদের জন্য ওপেন সোর্স প্রকল্প, এআই কোডিং এজেন্ট, গিথুব স্টার]
[হিন্দিতে কোডিং, টেক শর্টস হিন্দি, নতুনদের জন্য প্রোগ্রামিং, ডেভেলপার টুল 2026, ডেভেলপারদের জন্য এআই টুলস, ফ্রি কোডিং শিখুন]
9 likes · 2 comments · 285 views
ministack: Free local AWS emulator for your dev machine
Run 60+ AWS services on one port with no sign-up. It's drop-in compatible with boto3, the AWS CLI and Terraform, and uses real databases. Perfect for fast local testing and CI pipelines.
Built by the Ministack.org team: ministackorg/ministack
1 point
Free AWS emulator you can run locally
Run 60+ AWS services on one port with no sign-up. It's drop-in compatible with boto3, the AWS CLI and Terraform, and uses real databases.
🍒 Built by the Ministack.org team
ministackorg/ministack
#opensource #python
1 like
What Replaced queue:work in My Laravel App (And Why I Stopped Praying to Supervisor at 3 AM)
> Versión en español aquí.
---
!Banner Image
---
In the previous chapter we figured out where our data lives: files in S3, cache and sessions in DynamoDB, and a NoSQL database that speaks Eloquent. But one question was left hanging in the air: if a Lambda function is born, responds, and dies in milliseconds, who stays awake processing jobs in the background?
On a traditional server, the answer is supervisord babysitting a long-running process. In an ephemeral environment where nothing lives longer than 15 minutes, that recipe simply doesn't work.
In this chapter we're going to replace that daemon with SQS + a Lambda worker. We'll do it in three acts: first the setup _(surprisingly short)_, then the lifecycle of a job _(where the magic happens)_, and finally the topic almost nobody explains in full: what happens when a job fails, who retries it, and where it ends up.
---
1. The Serverless Treasure Map
Quick recap, so we know where we are:
!AWS Serverless architecture
We already have Lambda running our Laravel app, CloudFront splitting traffic between S3 _(assets)_ and Lambda _(the app)_, and DynamoDB serving as cache, sessions, and our primary database. Today's agenda:
- Managing our event queue with SQS,
- and a second Lambda function for the Laravel Queue _worker_.
Remember the component table from the second chapter? Today we cross off this row:
| Component | Dedicated Server | AWS Serverless |
|-------------|-------------------------------------------------------|-----------------|
| Event Queue | SSH $ php artisan queue:work / maybe supervisor | SQS + Lambda 🚀 |
---
2. Why queue:work Doesn't Fit in Serverless
a) How queues work on a traditional server
Oversimplifying a bit, php artisan queue:work is a while (true) loop: it asks the queue if there's any work; if there is, it processes it; if not, it sleeps for a few seconds and asks again. A resident process hogging memory and CPU 24/7, even if the queue has been empty for 3 days.
And because it's a long-running process, it needs a babysitter:
- supervisor _(or systemd)_ to resurrect it when it dies.
- queue:restart after every deploy, because the process still has the old code loaded in memory.
- --max-jobs and --max-time to recycle it before memory leaks turn it into a black hole.
- And to scale? More processes → more RAM → bigger instances → all accompanied by a bigger bill.
> queue:work is an employee getting paid to hit F5 on their inbox every three seconds. Eight hours a day. Even when nothing comes in.
b) Why that doesn't exist in Lambda
A Lambda invocation lives, at most, for 15 minutes [^1]. There is no resident process, no while (true), and nobody asking _"got any work?"_. If you try running queue:work inside a Lambda... it works for 15 minutes, dies, and nobody brings it back. And if you resurrect it with a cron job, you're paying for a Lambda 24/7 just to poll. Congratulations, you just reinvented EC2, only more expensive.
c) Flipping the model: from _pull_ to _push_
The solution isn't to _adapt_ the worker to Lambda—it's to flip it upside down. Instead of the worker pulling work _(pull)_, the queue invokes the worker when work arrives _(push)_:
1. Laravel calls dispatch() and pushes the job to SQS.
2. SQS detects new messages and invokes a Lambda function with those messages.
3. Lambda processes the jobs, finishes, and disappears.
4. An empty queue = zero Lambda invocations.
> _The worker stops being a process that waits and becomes an event that happens._
---
3. The New Queue Stack: SQS as the Mailbox, Lambda as the Worker
a) SQS in 30 seconds for Laravel devs
Amazon SQS _(Simple Queue Service)_ is a managed message queue [^2]. The bare minimum you need to know:
- Message: for us, the serialized JSON payload of the job. The exact same one you'd see in the payload column of the jobs table if you were using the database driver.
- Visibility timeout: when someone picks up a message, SQS _hides_ it for X seconds. If nobody deletes it within that window, it reappears so another consumer can try again. It's the equivalent of Laravel's retry_after.
- Dead Letter Queue _(DLQ)_: a second queue where SQS moves messages that have failed too many times. The _"lost and found box"_.
- Standard vs FIFO: standard queues don't guarantee order _(and they're the ones we'll use)_; FIFO queues do, in exchange for lower throughput.
> _With the vocabulary out of the way, let's jump into the code._
b) Wiring it up with Serverless Lift
Starting from the serverless.yml we built in the second chapter, we add two environment variables and a Lift construct [^3] [^4]:
service: laravel
provider:
name: aws
environment:
# ...
+ QUEUE_CONNECTION: sqs
+ SQS_QUEUE: ${construct:jobs.queueUrl}
functions:
web:
handler: Bref\LaravelBridge\Http\OctaneHandler
runtime: php-85
# ...
constructs:
website:
type: server-side-website
# ...
+ jobs:
+ type: queue
+ worker:
+ handler: Bref\LaravelBridge\Queue\QueueHandler
+ runtime: php-85
+ timeout: 60 # seconds, the ceiling for the longest job
plugins:
- ./vendor/bref/bref
- serverless-lift
QUEUE_CONNECTION: sqs enables the driver Laravel ships with out of the box, and SQS_QUEUE receives the full URL of the queue Lift just created _(if you prefer passing only the queue name, you'll also need SQS_PREFIX [^3])_.
> _Even though on a traditional server the queue runs via CLI with artisan, here we don't use php-85-console. Just like with OctaneHandler, we use the php-85 event runtime with Bref's QueueHandler class._
And what did Lift do behind the scenes with that jobs construct? [^4]
- Created the SQS queue.
- Created a Dead Letter Queue with a 14-day retention period _(the maximum SQS allows)_.
- Created the worker Lambda function with Bref's handler.
- Configured the event source mapping: the AWS piece that connects the SQS queue to the Lambda function and invokes it when messages arrive. Our replacement for queue:work.
- And granted sqs:SendMessage permission to all functions in the stack [^5]. That's why, unlike the DynamoDB tables from the previous chapter, we didn't have to write a single line of IAM here.
> _Credentials, just like with DynamoDB, are assigned to each function's execution role. They rotate automatically and nobody ever sees them._
c) New infrastructure, same old code
This is my favorite part. Nothing you've already written has any idea anything changed:
// A job
SendWelcomeEmail::dispatch($user->uuid);
// A queued mailable
Mail::to($user)->queue(new WelcomeMail($user));
// A notification implementing ShouldQueue
$user->notify(new VerifyEmail);
All of that goes to SQS, and a Lambda processes it. ShouldQueue listeners, chained jobs... almost everything works the exact same way. Another point for Laravel's abstraction 💪.
> Important: Laravel's SQS driver caps delay() / #[Backoff] at 900 seconds (15 minutes), which is the SQS limit [^6].
> If you need multiple queues _(one for urgent emails, another with lower concurrency to avoid hammering an external API, a FIFO queue for strict ordering)_, each one is an additional queue construct, and since Laravel 13.26 they can be routed with Queue::forward() [^7].
---
4. What Actually Happens When You Dispatch a Job?
Up to this point it all looks like magic: we added a block in serverless.yml, called dispatch(), and the job ran. But in production, magic only lasts until the first incident. Let's walk step by step through _who_ does _what_, from the moment a message enters SQS until it disappears.
a) Laravel dispatches, SQS receives
When you call SendWelcomeEmail::dispatch($user->uuid), Laravel serializes the job and the sqs driver makes a SendMessage call to the SQS API to drop it into the queue. And that's it—our web Lambda has done its part and can finish its execution.
> _The message body is the same old JSON (displayName, job, data, uuid, attempts). To Laravel, SQS is just another mailbox to drop that exact same envelope into._
b) SQS notifies, Lambda wakes up
The _event source mapping_ that Lift created handles _long polling_ for us [^8]: AWS keeps an internal process polling the queue and, as soon as it detects messages, invokes our worker Lambda function with a batch of up to batchSize messages _(Lift defaults this to 1; we'll see why and when to change it shortly)_. Polling still exists; the difference is that instead of your queue:work process doing it, AWS takes care of it now.
> Important: That AWS polling counts as ReceiveMessage calls in SQS, even when the queue is empty. Fortunately, the free tier includes 1 million monthly requests [^9]: with an idle queue, that constant check generates ~21,000 requests per day _(~630,000 per month)_, so you pay nothing.
c) Bref receives the batch, Laravel runs the job
When Lambda wakes up, it hands the SQS event to Bref's QueueHandler [^10]. If we peek inside its handleSqs() method _(simplified)_, we can see exactly what happens with each message:
public function handleSqs(SqsEvent $event, Context $context): void
{
$worker = $this->container->makeWith(Worker::class, [
'resetScope' => fn () => $this->resetLaravel(),
// ...
]);
foreach ($event->getRecords() as $sqsRecord) {
$timeout = $this->calculateJobTimeout($context->getRemainingTimeInMillis());
$worker->runSqsJob(
$job = $this->marshalJob($sqsRecord),
$this->connection,
$this->gatherWorkerOptions($timeout),
);
if (! $job->hasFailed() && ! $job->isDeleted()) {
$job->delete();
}
}
}
Three key details in those few lines:
1. That Worker is Laravel's. It literally extends Illuminate\Queue\Worker [^11]. Bref didn't reinvent how to run a job: it passes each message to the exact same engine that queue:work uses. Direct consequence: #[Tries], #[Backoff], $timeout, failed(), middleware, ShouldBeUnique, retryUntil(), and the JobProcessing/JobProcessed/JobFailed events... everything works just like on a traditional server. There's no _"supported features"_ table to check.
2. State is cleaned up between jobs. That resetLaravel() callback flushes log context, forgets _scoped_ container instances, and clears resolved facades before each run. Why? For the exact same reason we saw with Octane in the second chapter: the Lambda instance _(and its PHP process)_ survives from one job to the next. Without that cleanup, the second job would inherit the in-memory leftovers of the first.
3. Deleting from SQS is explicit. Only if the job finished without failing does Bref call $job->delete() to remove it from the queue. If it failed or was already deleted _(or released back to the queue, as we'll see next)_, it leaves it alone.
d) Lambda's timeout vs. the job's $timeout
Did you spot that calculateJobTimeout($context->getRemainingTimeInMillis()) inside the loop? Bref doesn't use a fixed timeout: it checks how many milliseconds the Lambda invocation has left to live _(minus a 1-second safety margin)_ and assigns that time to the job. With that value, the Worker schedules a pcntl_alarm() [^11]: if the job runs over, the alarm goes off, the process exits with exit(1), and the invocation fails. The job is only marked as failed _(and sent to failed_jobs)_ if you defined #[FailOnTimeout] or $failOnTimeout = true.
Two practical takeaways:
- The worker's timeout in serverless.yml is, in practice, the ceiling for the longest job you can process. Set it with your heaviest task in mind.
- With a batchSize greater than 1, all jobs in the batch share that same clock: if you process long-running tasks, keep the batch small _(or leave it at 1)_.
> Watch out for #[Timeout] / $timeout: If you define a custom timeout on the job class, Laravel overrides the value calculated by Bref. If that number exceeds the time remaining in Lambda, AWS will kill the invocation cold before PHP's alarm ever rings. Recommendation: use a value well below the worker's timeout, especially with batchSize greater than 1, where the last jobs in the batch start with less time available.
---
5. Who Takes Over When Something Blows Up?
We all love watching the happy path play out by the book: the job comes in, runs, and gets deleted from SQS. But in the real world, external APIs go down, validations fail, and processes run out of memory.
> You're probably wondering: _"If Lift already created a Dead Letter Queue in SQS and Laravel already has its own failed_jobs table, aren't they doing the exact same thing?"_
The answer is no. When we combine Laravel's Worker with SQS and Lambda, two retry and failure mechanisms coexist in the same stack: one on our application side and another on the AWS infrastructure side. And even though they sound similar, they tackle completely different problems.
a) Retries are handled by Laravel, not SQS
Let's start on our application side. Picture an ordinary job configured with retries and backoff:
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
#[Tries(3)]
#[Backoff(30)]
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(public readonly string $user_uuid) {}
public function handle(): void
{
// ... if this throws an exception, Laravel retries in 30 seconds, up to 3 times
}
public function failed(Throwable $e): void
{
// ... once all 3 attempts are exhausted, Laravel calls this
}
}
When handle() throws an exception, Laravel's Worker does what it always does: if attempts remain, it calls $job->release(30). And here's the trick. Laravel's native SqsJob simply updates the message's _visibility timeout_ in SQS, but Bref overrides it and does something else [^12]:
public function release($delay = 0)
{
$this->released = true;
$payload = $this->payload();
$payload['attempts'] = ($payload['attempts'] ?? 0) + 1;
// deletes the original message...
$this->sqs->deleteMessage([
'QueueUrl' => $this->queue,
'ReceiptHandle' => $this->job['ReceiptHandle'],
]);
// ...and sends a brand-new copy, with the updated attempt counter and backoff delay
$this->sqs->sendMessage([
'QueueUrl' => $this->queue,
'MessageBody' => json_encode($payload),
'DelaySeconds' => $this->secondsUntil($delay),
]);
}
> It's not _"forwarding"_. It's deleting the email and composing a brand-new one, identical, with [Attempt 2] in the subject line and scheduled to send in 30 seconds.
Why? Because that way the attempt counter travels inside the payload _(attempts() reads it from there, not from SQS's ApproximateReceiveCount)_, and backoff translates into a real DelaySeconds. As a result, #[Tries(3)] and #[Backoff(30)] behave exactly like they do on a server. Bref deliberately chose Laravel's semantics over SQS's [^3].
> If you don't define #[Tries] / $tries, Bref's handler defaults to 3 [^13]. And if you use exponential backoff _(#[Backoff([10, 60, 300])])_, that works too: each re-queued message carries the delay corresponding to that attempt.
b) The side effect: SQS never finds out about the failure
When an exception is thrown inside handle(), Laravel's Worker catches it in its own try/catch: it reports it to the logs, decides whether to retry or mark the job as failed, and moves on. Because that exception never bubbles up to handleSqs(), as far as AWS Lambda is concerned, the function succeeded without errors.
This means SQS's native retry mechanism _(visibility timeout → maxReceiveCount → DLQ)_ never finds out that a job failed due to a PHP exception. Not on the first attempt, and not on the third.
> _Laravel is that teammate who says "all good" in the daily standup while production is on fire. And SQS believes them._
c) First layer: when the job exhausts its attempts in Laravel
When a job fails for the third time and runs out of attempts, Laravel gives up: it calls the class's failed() method and records the failure using the driver configured in config/queue.php _(queue.failed)_.
The problem is that, by default, Laravel tries to save that record in a failed_jobs table inside a SQL database... which we said goodbye to in the previous chapter. Without a relational database, the quick way out is to disable it altogether with QUEUE_FAILED_DRIVER: null [^3]: the exception still ends up in CloudWatch logs, but the job itself isn't stored anywhere. I don't recommend it.
The good news is that Laravel already includes native support for storing failed_jobs in DynamoDB [^14]. We only need to tweak config/queue.php:
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
// When using driver=dynamodb there's no need to specify 'database'
// 'database' => env('DB_CONNECTION', 'sqlite'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'token' => env('AWS_SESSION_TOKEN'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_FAILED_JOBS_TABLE', 'failed_jobs'),
],
provider:
environment:
QUEUE_FAILED_DRIVER: dynamodb
DYNAMODB_FAILED_JOBS_TABLE: !Ref FailedJobsTable
And we declare the table under resources in serverless.yml. Laravel uses application as the partition key _(the app name)_ and uuid as the sort key:
FailedJobsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: failed_jobs
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: application, AttributeType: S }
- { AttributeName: uuid, AttributeType: S }
KeySchema:
- { AttributeName: application, KeyType: HASH }
- { AttributeName: uuid, KeyType: RANGE }
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
> Just like we did in the previous chapter for the cache and session tables, we use TimeToLiveSpecification; Laravel saves each failed job with an expiration timestamp _(expires_at, 7 days by default [^14])_. This driver doesn't even implement queue:flush: DynamoDB takes care of cleaning up expired records automatically.
Finally, we grant the execution role permissions on the table:
provider:
iam:
role:
statements:
- Effect: Allow
Resource: !GetAtt FailedJobsTable.Arn
Action:
- dynamodb:PutItem
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:DeleteItem
To list or retry failed jobs—since there's no SSH in Lambda—we use the bref:cli bridge we saw in the second chapter:
serverless bref:cli --args="queue:failed"
serverless bref:cli --args="queue:retry all"
> _"Wait, so what is the DLQ that Lift created even for?"_
d) Second layer: when the infrastructure fails
A message only reaches the _Dead Letter Queue_ when the entire Lambda invocation fails, without giving Laravel a chance to catch the error. For example:
- A job exceeded the remaining invocation time (pcntl_alarm went off and execution terminated with exit(1)).
- It ran out of memory _(OOM)_.
- A fatal PHP error outside the scope of the Worker.
- A boot failure: a missing extension, a configuration error, a composer install that left something out.
In any of these scenarios, Lambda dies before Bref can delete the message, so SQS's native mechanism kicks in:
1. Wait (_visibility timeout_): the message stays hidden until its visibility timeout expires. Lift automatically sets it to 6 times the worker's timeout following AWS's recommendation [^15] _(with the 60 seconds from our example, it reappears after 6 minutes)_.
2. Retry: once visible again, SQS hands it back to Lambda.
3. Off to the DLQ: if the invocation fails 3 times in a row _(maxRetries by default in Lift)_, SQS removes the message from the main queue and stores it in the Dead Letter Queue for up to 14 days, preventing it from being lost.
To inspect what landed in the DLQ or re-queue it without opening the AWS console, Lift gives us these commands [^16]:
serverless jobs:logs --tail # live worker logs
serverless jobs:failed # inspect messages in the DLQ
serverless jobs:failed:retry # move everything back to the main queue
serverless jobs:failed:purge # delete everything and move on with life
Of course, nobody wants to run serverless jobs:failed every day just in case. If you add the alarm property to the construct, Lift automatically sets up a CloudWatch alarm that sends you an email as soon as a message lands in the DLQ [^4]:
constructs:
jobs:
type: queue
# ...
alarm: alerts@my-domain.com # email alert when the DLQ receives messages
> After the first serverless deploy, AWS will send a confirmation email to the address configured in alarm _(don't forget to confirm it to activate the alerts)_.
e) The two layers, side by side
| | First layer: failed_jobs | Second layer: SQS DLQ |
|------------------------|----------------------------------------|--------------------------------------------------|
| What failed? | Our code: exception in handle() | Infrastructure: timeout, OOM, fatal error |
| Who retries? | Laravel (#[Tries], #[Backoff]) | SQS (_visibility timeout_, maxRetries) |
| What gets retried? | That job, after configured backoff | The in-flight job and rest of batch (6× timeout) |
| Where does it end? | failed_jobs table (DynamoDB, 7d) | Dead Letter Queue (SQS, 14 days) |
| How do I retry it? | bref:cli --args="queue:retry ..." | serverless jobs:failed:retry |
| How do I find out? | failed(), CloudWatch, queue:failed | Email alert (alarm:) |
Take a look at the What gets retried? row in the second layer. When Lambda dies mid-batch _(due to timeout or OOM)_, the jobs that had already finished were deleted one by one with $job->delete(), so they don't run again. SQS only re-delivers what remained undeleted: the job that was halfway through execution and the ones waiting behind it in the batch.
The danger lies in that half-finished job: when SQS retries it, it will run again from the very beginning. If it had already charged a credit card or sent an email a second before Lambda died and isn't idempotent, it will do it a second time _(and you'll get acquainted with the word "refund" much sooner than you'd like)_.
> Golden rule: always design idempotent jobs.
---
6. Tuning the Worker Knobs for Production
The 8 lines from section 3 are enough to get started, but now that we know what happens under the hood, we can tune the knobs for production. Here's what a battle-tested configuration looks like:
constructs:
jobs:
type: queue
maxRetries: 5 # queue maxReceiveCount: AWS recommends >= 5
maxConcurrency: 20 # the "clean" brake: Lambda stops polling, messages wait
worker:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-85
layers:
- ${bref-extra:gd-php-85}
reservedConcurrency: 25 # hard cap & reservation; always >= maxConcurrency
timeout: 300
batchSize: 10
a) timeout: the ceiling for the longest job
It defines both the maximum duration of an invocation and the queue's _visibility timeout_ in SQS _(which Lift multiplies by 6)_. In my case I bumped it to 300 seconds because the worker processes heavy images, and resizing a 20 MB photo takes time. If your jobs only send emails or call an API, 30 or 60 seconds is more than enough.
b) batchSize: fewer invocations, more responsibility
Lift leaves it at 1 by default to keep error handling simple [^17]. Raising it to 10 means a burst of 500 jobs is processed in 50 invocations instead of 500, reducing _cold-starts_ and costs. In exchange, remember the two conditions we covered earlier: all jobs in the batch share the same Lambda clock, and our jobs must be idempotent in case an invocation dies mid-batch.
> There's also maxBatchingWindow: how many seconds SQS waits to gather a full batch before invoking Lambda. Zero by default. If you don't mind a job waiting a few seconds for company to arrive, you can raise it.
c) reservedConcurrency vs maxConcurrency
If 1,000 jobs arrive at once, Lambda scales up fast to hundreds of simultaneous invocations. And that sounds wonderful until those 1,000 jobs slam into a rate-limited external API, our SMTP server, or DynamoDB itself all at the same time.
There are two ways to put a ceiling on it:
- reservedConcurrency _(function property)_: the function never runs more than N simultaneous instances. But it also reserves those N from your account's total concurrency quota, even when sitting idle [^18].
- maxConcurrency _(Lift construct property)_: limits how many concurrent invocations this queue's event source mapping can trigger, without reserving anything [^19].
The sweet spot is combining both: maxConcurrency as a clean brake _(messages wait in the queue without burning attempts)_ and reservedConcurrency slightly above it, as a safety net in case a bug dispatches jobs in an infinite loop.
> Heads up: if you use only reservedConcurrency, anything above that limit gets _throttled_, and every rejection counts as a delivery attempt in SQS. With maxRetries: 3 _(Lift's default)_, a traffic spike can send jobs straight to the DLQ without ever having executed once. That's why AWS recommends setting maxRetries to at least 5 [^20].
d) memorySize: more memory is also more CPU
As we saw in the second chapter, CPU power in Lambda scales proportionally with allocated memory. A heavy job that takes 10 seconds with 512 MB might finish in 3 seconds with 1,769 MB; and since AWS bills per GB-second, giving it more memory often ends up being cheaper. If your worker does CPU-intensive work, you can assign its own memorySize and find the sweet spot with aws-lambda-power-tuning [^21].
e) Tailored layers: extensions only where needed
Did you notice the ${bref-extra:gd-php-85} layer in the configuration? My worker needs the GD extension to process images, but the web HTTP function doesn't. Because they are separate Lambda functions, each one loads only the extensions it actually uses: web requests stay lightweight, and the extra weight is carried exclusively by the background worker.
f) Does the worker need a warmer?
In the second chapter we set up a _warmer_ every 5 minutes to avoid _cold-starts_ on web requests. Does the worker need one? Not at all. A 200 ms _cold-start_ on an HTTP route is felt by a user staring at a loading screen; in a background queue, an extra 200 ms won't keep anyone up at night.
---
7. What Tutorials Don't Tell You (and You Find Out in Production)
Things that aren't in the docs _(or were buried in the fine print and I learned the hard way)_:
a) SerializesModels and DynamoDB composite keys
Did you notice that in SendWelcomeEmail _(section 5.a)_ I passed $user_uuid as a string instead of the User model? That wasn't an accident. SerializesModels stores only getKey() _(PK)_ and tries to restore the model with a traditional SQL query that doesn't include the SK or support DynamoDB.
For your own jobs, the simplest approach is passing the uuid to the constructor and fetching the model inside handle(). However, if you use queued notifications _($user->notify())_, Laravel serializes the User model whether you like it or not: in that case, you have no choice but to override getQueueableId() and newQueryForRestoration() on your model to encode and reconstruct PK + SK.
b) Bus::batch() also needs somewhere to store its progress
By default it looks for the relational job_batches table, but Laravel includes native DynamoDB support: you just need to create the job_batches table _(partition key application, sort key id)_, grant IAM permissions, and set queue.batching.driver to dynamodb [^22].
c) Your local environment doesn't need to touch AWS
Since our job code doesn't change based on the driver, on your machine you can keep using QUEUE_CONNECTION=database _(or sync)_ with good old queue:work, or emulate SQS locally with tools like MiniStack [^23] or LocalStack [^24]. QUEUE_CONNECTION=sqs pointing to AWS is only needed in serverless.yml.
d) The limit is 1 MiB per message
That's SQS's hard limit [^25]. Jobs should carry lightweight identifiers, not base64-encoded files or collections of 10,000 items. If you're anywhere near that limit, your design is screaming at you to have the job fetch the data when it runs instead of receiving it in the payload.
e) The AWS SDK cleanup from the second chapter
If you used aws/aws-sdk-php with removeUnusedServices in composer.json to shrink your deploy size, make sure to add Sqs to the list of allowed services:
"extra": {
"aws/aws-sdk-php": [
"DynamoDb",
"S3",
"Sqs"
]
}
Why is it so easy to forget? Because tests run with QUEUE_CONNECTION=sync or Queue::fake(), so CI passes everything in green without ever instantiating SqsClient... until the first real dispatch() in production blows up because the class was stripped out.
---
8. Costs: The Worker That Only Charges When It Works
Let's update the tab, just like in every chapter.
- Before (EC2): the worker lived on one of the t3.small instances from the first chapter _(~$15/month per instance)_. A resident queue:work process with reserved RAM and supervisord running 24/7, even when the queue spent 90% of the day empty.
- Now (SQS + Lambda):
- SQS: the first million monthly requests are free; after that it costs \$0.40 per million [^26]. Adding up the ~630,000 requests from idle polling and 3 requests per job _(SendMessage, ReceiveMessage, and DeleteMessage)_, you can process up to ~120,000 jobs per month for $0.00.
- Lambda: the perpetual free tier includes 1 million invocations and 400,000 GB-seconds per month. With no jobs in the queue, compute costs literally zero.
Let's put numbers on a month with 100,000 jobs _(averaging ~500 ms with 512 MB of RAM)_:
| Item | Within Free Tier | Full Price _(no Free Tier)_ |
|----------------------------|---------------------:|--------------------------------:|
| SQS Polling (~630k req/mo) | $0.00 | $0.25 |
| SQS Messages (300k req/mo) | $0.00 | $0.12 |
| Lambda Worker (100k jobs) | $0.00 | $0.44 |
| Monthly total | $0.00 | ~$0.81 |
> _Even without the free tier, processing 100,000 jobs costs less than a dollar a month!_
And most importantly: with DynamoDB on PAY_PER_REQUEST, Lambda billing per millisecond, and SQS billing per request, almost no component in the queue system charges a flat fee just for existing _(except for SQS polling, which in my case falls within the free tier)_.
---
9. What's Next?
Today we retired supervisord and the 24/7 queue:work process for good: we replaced them with an SQS queue and a Lambda worker that only wakes up when there's work to do. We saw how Bref reuses Laravel's own Worker _(so our jobs don't change)_, how its release() trick preserves retries and backoff, and how failed_jobs in DynamoDB coexists with SQS's _Dead Letter Queue_.
But there's still a loose end between the previous chapter and this one. In section 6 I mentioned that my worker loads the GD extension to resize images; now, if in the previous chapter we saw that files travel straight from the browser to S3 without touching our application... who tells Laravel that a file finished uploading so it can queue that job?
In the next chapter we'll close that loop with S3 Event Notifications, and we'll see why the solution is _not_ wiring S3 directly to the queue we just built.
---
A question for you: what are you running your Laravel queues on in production today, or what was the worst stunt a queue worker ever pulled on you at 3 AM? Let me know in the comments: the best production war stories come from there, not from post-mortems.
[^1]: docs.aws.amazon.com
[^2]: docs.aws.amazon.com
[^3]: bref.sh
[^4]: github.com/getlift/lift/blob/master/docs/queue.md
[^5]: github.com/getlift/lift/blob/master/docs/permissi…
[^6]: docs.aws.amazon.com
[^7]: bref.sh
[^8]: docs.aws.amazon.com
[^9]: aws.amazon.com
[^10]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^11]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^12]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^13]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^14]: laravel.com
[^15]: github.com/getlift/lift/blob/master/docs/queue.md
[^16]: github.com/getlift/lift/blob/master/docs/queue.md
[^17]: github.com/getlift/lift/blob/master/docs/queue.md
[^18]: docs.aws.amazon.com
[^19]: github.com/getlift/lift/blob/master/docs/queue.md
[^20]: docs.aws.amazon.com
[^21]: alexcasalboni/aws-lambda-power-tuning
[^22]: laravel.com
[^23]: ministackorg/ministack
[^24]: localstack/localstack
[^25]: docs.aws.amazon.com
[^26]: aws.amazon.com
3 reactions · 2 comments
Qué Reemplazó a queue:work en Mi Aplicación Laravel (Y Por Qué Dejé de Rezarle a Supervisor a las 3 AM)
> English version here.
---
!Banner Image
---
En el capítulo anterior resolvimos dónde viven los datos: archivos en S3, caché y sesiones en DynamoDB, y una base de datos NoSQL que habla Eloquent. Pero quedó una pregunta flotando en el aire: si una función Lambda nace, responde y muere en milisegundos, ¿quién se queda despierto procesando los jobs en segundo plano?
En un servidor tradicional la respuesta es supervisord vigilando un proceso eterno. En un entorno efímero donde nada vive más de 15 minutos, esa receta simplemente no aplica.
En este capítulo vamos a reemplazar ese daemon por SQS + un worker en Lambda. Lo haremos en tres actos: primero la configuración _(sorprendentemente corta)_, después el ciclo de vida de un job _(donde está la magia)_, y finalmente el tema que casi nadie explica completo: qué pasa cuando un job falla, quién lo reintenta, y dónde termina.
---
1. El Mapa del Tesoro Serverless
Rápido, para ubicarnos:
!AWS Serverless architecture
Ya tenemos Lambda ejecutando nuestra App Laravel, CloudFront repartiendo tráfico entre S3 _(assets)_ y Lambda _(la app)_, y DynamoDB como caché, sesiones y nuestra base de datos principal. Hoy toca:
- Gestionar nuestra cola de eventos con SQS,
- y una segunda función Lambda para el _worker_ de Laravel Queue.
¿Recuerdan la tabla de componentes del segundo capítulo? Hoy tachamos esta fila:
| Componente | Servidor Dedicado | AWS Serverless |
|-----------------|--------------------------------------------------------|-----------------|
| Cola de Eventos | SSH $ php artisan queue:work / quizás supervisor | SQS + Lambda 🚀 |
---
2. Por Qué queue:work No Encaja en Serverless
a) Cómo funciona la cola en un servidor tradicional
Simplificando mucho, php artisan queue:work es un while (true): le pregunta a la cola si hay trabajo; si hay, lo procesa; si no, duerme unos segundos y vuelve a preguntar. Un proceso residente que ocupa memoria y CPU las 24 horas, aunque la cola esté vacía desde hace 3 días.
Y como es un proceso, necesita niñera:
- supervisor _(o systemd)_ para resucitarlo cuando muere.
- queue:restart después de cada deploy, porque el proceso tiene el código viejo cargado en memoria.
- --max-jobs y --max-time para reciclarlo antes de que los memory leaks lo conviertan en un agujero negro.
- ¿Y para escalar? Más procesos → más RAM → instancias más grandes → todo acompañado de una factura mayor.
> queue:work es un empleado al que le pagan por presionar F5 en la bandeja de entrada cada tres segundos. Ocho horas. Aunque no llegue nada.
b) Por qué eso no existe en Lambda
Una invocación de Lambda vive, como máximo, 15 minutos [^1]. No hay proceso residente, no hay while (true), no hay nadie preguntando _"¿hay trabajo?"_. Si intentan correr queue:work dentro de una Lambda... funciona 15 minutos, muere, y nadie lo resucita. Y si lo resucitan con un cron, están pagando una Lambda las 24 horas para hacer polling. Felicidades, reinventaron EC2, pero más caro.
c) La inversión del modelo: de _pull_ a _push_
La solución no es _adaptar_ el worker a Lambda, es darlo vuelta. En vez de que el worker busque trabajo _(pull)_, la cola invoca al worker cuando hay trabajo _(push)_:
1. Laravel hace dispatch() y registra el job en SQS.
2. SQS detecta que hay mensajes e invoca a una función Lambda con esos mensajes.
3. Lambda procesa los jobs, termina y desaparece.
4. Una cola vacía = cero invocaciones Lambda.
> _El worker deja de ser un proceso que espera y pasa a ser un evento que ocurre._
---
3. El Nuevo Stack de Colas: SQS como Buzón, Lambda como Worker
a) SQS en 30 segundos para devs Laravel
Amazon SQS _(Simple Queue Service)_ es una cola de mensajes administrada [^2]. Lo mínimo que necesitan saber:
- Mensaje: para nosotros, el payload JSON serializado del job. El mismo que verían en la columna payload de la tabla jobs si usaran el driver database.
- Visibility timeout: cuando alguien toma un mensaje, SQS lo _oculta_ durante X segundos. Si en ese tiempo nadie lo borra, reaparece para que otro lo intente. Es el equivalente al retry_after de Laravel.
- Dead Letter Queue _(DLQ)_: una segunda cola donde SQS mueve los mensajes que fallaron demasiadas veces. La _"caja de objetos perdidos"_.
- Standard vs FIFO: las colas estándar no garantizan orden _(y son las que vamos a usar)_; las FIFO sí, a cambio de menos throughput.
> _Con el vocabulario listo, pasemos al código._
b) Conectando los cables con Serverless Lift
Partimos del serverless.yml que construimos en el segundo capítulo y agregamos dos variables de entorno y un construct de Lift [^3] [^4]:
service: laravel
provider:
name: aws
environment:
# ...
+ QUEUE_CONNECTION: sqs
+ SQS_QUEUE: ${construct:jobs.queueUrl}
functions:
web:
handler: Bref\LaravelBridge\Http\OctaneHandler
runtime: php-85
# ...
constructs:
website:
type: server-side-website
# ...
+ jobs:
+ type: queue
+ worker:
+ handler: Bref\LaravelBridge\Queue\QueueHandler
+ runtime: php-85
+ timeout: 60 # segundos, el techo del job más largo
plugins:
- ./vendor/bref/bref
- serverless-lift
QUEUE_CONNECTION: sqs activa el driver que Laravel ya trae de fábrica, y SQS_QUEUE recibe la URL completa de la cola que Lift acaba de crear _(si prefieren pasar solo el nombre, van a necesitar también SQS_PREFIX [^3])_.
> _Aunque en un servidor tradicional la cola se corre por CLI con artisan, acá no usamos php-85-console. Al igual que con OctaneHandler, usamos el runtime de eventos php-85 con la clase QueueHandler de Bref._
¿Y qué hizo Lift por detrás con ese construct jobs? [^4]
- Creó la cola SQS.
- Creó una Dead Letter Queue con retención de 14 días _(el máximo que permite SQS)_.
- Creó la función Lambda worker con el handler de Bref.
- Configuró el event source mapping: la pieza de AWS que conecta la cola SQS con la función Lambda, y la invoca cuando hay mensajes. Nuestro reemplazo de queue:work.
- Y le dio permiso sqs:SendMessage a todas las funciones del stack [^5]. Por eso, a diferencia de las tablas DynamoDB del capítulo anterior, acá no escribimos ni una línea de IAM.
> _Las credenciales, al igual que con DynamoDB, las tiene asignadas el rol de ejecución de cada función. Rotan solas y nadie las ve._
c) Nueva infraestructura, el mismo código de siempre
Esta es la parte que más me gusta. Nada de lo que ya escribieron se entera del cambio:
// Un job
SendWelcomeEmail::dispatch($user->uuid);
// Un mail encolado
Mail::to($user)->queue(new WelcomeMail($user));
// Una notificación que implementa ShouldQueue
$user->notify(new VerifyEmail);
Todo eso va a SQS, y una Lambda lo procesa. Los listeners ShouldQueue, los jobs encadenados... casi todo sigue funcionando igual. Otro punto a favor para la abstracción de Laravel 💪.
> Importante: El driver SQS de Laravel limita el delay() / #[Backoff] hasta 900 segundos (15 minutos), que es el límite de SQS [^6].
> Si necesitan varias colas _(una para emails urgentes, otra con menos concurrencia para no reventar una API externa, una FIFO para procesar en orden)_, cada una es un construct queue adicional, y desde Laravel 13.26 se enrutan con Queue::forward() [^7].
---
4. ¿Qué Pasa Realmente Cuando Despachan un Job?
Hasta acá todo parece magia: configuramos un bloque en serverless.yml, hicimos dispatch() y el job se ejecutó. Pero en producción la magia dura hasta el primer incidente. Veamos paso a paso _quién_ hace _qué_, desde que el mensaje entra a SQS hasta que desaparece.
a) Laravel despacha, SQS recibe
Cuando llaman a SendWelcomeEmail::dispatch($user->uuid), Laravel serializa el job y el driver sqs ejecuta una llamada SendMessage a la API de SQS para dejarlo en la cola. Y listo; nuestra Lambda web ya cumplió su parte y puede terminar su ejecución.
> _El cuerpo del mensaje es el JSON de siempre (displayName, job, data, uuid, attempts). Para Laravel, SQS es solo otro buzón donde depositar ese mismo sobre._
b) SQS avisa, Lambda despierta
El _event source mapping_ que creó Lift hace el _long polling_ por nosotros [^8]: AWS mantiene un proceso interno consultando la cola y, apenas detecta mensajes, invoca a nuestra función Lambda worker con un lote de hasta batchSize mensajes _(Lift lo deja en 1 por defecto; ya veremos por qué y cuándo cambiarlo)_. El polling sigue existiendo; la diferencia es que, en vez del proceso queue:work, ahora AWS se encarga de hacerlo.
> Importante: Ese polling de AWS cuenta como llamadas ReceiveMessage en SQS, incluso con la cola vacía. Por suerte, la capa gratuita incluye 1 millón de requests mensuales [^9]: con la cola en reposo, ese chequeo constante genera ~21.000 requests por día _(~630.000 al mes)_, por lo que no pagan nada.
c) Bref recibe el lote, Laravel ejecuta el job
Cuando Lambda despierta, le entrega el evento de SQS al QueueHandler de Bref [^10]. Si espiamos su método handleSqs() _(simplificado)_ se ve exactamente qué pasa con cada mensaje:
public function handleSqs(SqsEvent $event, Context $context): void
{
$worker = $this->container->makeWith(Worker::class, [
'resetScope' => fn () => $this->resetLaravel(),
// ...
]);
foreach ($event->getRecords() as $sqsRecord) {
$timeout = $this->calculateJobTimeout($context->getRemainingTimeInMillis());
$worker->runSqsJob(
$job = $this->marshalJob($sqsRecord),
$this->connection,
$this->gatherWorkerOptions($timeout),
);
if (! $job->hasFailed() && ! $job->isDeleted()) {
$job->delete();
}
}
}
Tres detalles clave en esas pocas líneas:
1. Ese Worker es el de Laravel. Literalmente extiende de Illuminate\Queue\Worker [^11]. Bref no reinventó cómo correr un job: le pasa cada mensaje al mismo motor que usa queue:work. Consecuencia directa: #[Tries], #[Backoff], $timeout, failed(), middlewares, ShouldBeUnique, retryUntil() y los eventos JobProcessing/JobProcessed/JobFailed... todo funciona igual que en un servidor tradicional. No hay una tabla de _"features soportadas"_ que consultar.
2. Entre job y job se limpia el estado. Ese callback resetLaravel() vacía el contexto de logs, olvida las instancias _scoped_ del contenedor y limpia las facades resueltas antes de cada ejecución. ¿Por qué? Por la misma razón que vimos con Octane en el segundo capítulo: la instancia de Lambda _(y su proceso PHP)_ sobrevive entre un job y el siguiente. Sin esa limpieza, el segundo job heredaría la basura en memoria del primero.
3. El borrado en SQS es explícito. Solo si el job terminó sin fallar, Bref llama a $job->delete() para quitarlo de la cola. Si falló o ya fue eliminado _(o liberado de vuelta a la cola, como veremos enseguida)_, no lo toca.
d) El timeout de Lambda vs. el $timeout del job
¿Vieron ese calculateJobTimeout($context->getRemainingTimeInMillis()) en el loop? Bref no usa un timeout fijo: consulta cuántos milisegundos le quedan de vida a la invocación de Lambda _(menos 1 segundo de margen)_ y le asigna ese tiempo al job. Con ese valor, el Worker programa un pcntl_alarm() [^11]: si el job se pasa, suena la alarma, el proceso termina con exit(1) y la invocación falla. El job solo se marca como fallido _(y va a failed_jobs)_ si definieron #[FailOnTimeout] o $failOnTimeout = true.
Dos implicaciones prácticas:
- El timeout del worker en serverless.yml es, en la práctica, el techo del job más largo que pueden procesar. Configúrenlo pensando en su tarea más pesada.
- Con un batchSize mayor a 1, todos los jobs del lote comparten ese mismo reloj: si procesan tareas largas, conviene mantener un lote pequeño _(o dejarlo en 1)_.
> Ojo con #[Timeout] / $timeout: Si definen un timeout propio en la clase del job, Laravel pisa el valor calculado por Bref. Si ese número supera el tiempo que le queda a Lambda, AWS matará la invocación en seco antes de que suene la alarma de PHP. Recomendación: usen un valor bastante menor al timeout del worker, sobre todo con batchSize mayor a 1, donde los últimos jobs del lote arrancan con menos tiempo disponible.
---
5. ¿Quién Se Hace Cargo Cuando Algo Explota?
A todos nos gusta ver cuando el camino feliz se cumple al pie de la letra: el job entra, se ejecuta y se borra de SQS. Pero en el mundo real las APIs externas se caen, las validaciones fallan y los procesos se quedan sin memoria.
> Seguramente se estarán preguntando: _"Si Lift ya nos creó una Dead Letter Queue en SQS y Laravel ya tiene su propia tabla failed_jobs, ¿acaso no están haciendo lo mismo?"_
La respuesta es no. Cuando combinamos el Worker de Laravel con SQS y Lambda, aparecen dos mecanismos de reintento y fallo conviviendo en el mismo stack: uno del lado de nuestra aplicación y otro del lado de la infraestructura de AWS. Y aunque suenen parecidos, atacan problemas completamente distintos.
a) Los reintentos los maneja Laravel, no SQS
Empecemos por el lado de nuestra aplicación. Imaginen un job común y corriente configurado con reintentos y backoff:
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
#[Tries(3)]
#[Backoff(30)]
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(public readonly string $user_uuid) {}
public function handle(): void
{
// ... si esto lanza una excepción, Laravel reintenta en 30 segundos, hasta 3 veces
}
public function failed(Throwable $e): void
{
// ... agotados los 3 intentos, Laravel llama acá
}
}
Cuando handle() lanza una excepción, el Worker de Laravel hace lo de siempre: si quedan intentos, llama a $job->release(30). Y acá está el truco. El SqsJob nativo de Laravel simplemente modifica el _visibility timeout_ del mensaje en SQS, pero Bref lo sobreescribe y hace otra cosa [^12]:
public function release($delay = 0)
{
$this->released = true;
$payload = $this->payload();
$payload['attempts'] = ($payload['attempts'] ?? 0) + 1;
// borra el mensaje original...
$this->sqs->deleteMessage([
'QueueUrl' => $this->queue,
'ReceiptHandle' => $this->job['ReceiptHandle'],
]);
// ...y envía una copia nueva, con el contador de intentos y el delay del backoff
$this->sqs->sendMessage([
'QueueUrl' => $this->queue,
'MessageBody' => json_encode($payload),
'DelaySeconds' => $this->secondsUntil($delay),
]);
}
> No es un _"reenviar"_. Es borrar el correo y redactar uno nuevo, idéntico, con [Intento 2] en el asunto y programado para dentro de 30 segundos.
¿Por qué? Porque así el contador de intentos viaja dentro del payload _(attempts() lo lee de ahí, no del ApproximateReceiveCount de SQS)_, y el backoff se traduce a un DelaySeconds real. Resultado: #[Tries(3)] y #[Backoff(30)] se comportan exactamente como en un servidor. Bref eligió deliberadamente la semántica de Laravel por sobre la de SQS [^3].
> Si no definen #[Tries] / $tries, el handler de Bref usa 3 por defecto [^13]. Y si usan backoff exponencial _(#[Backoff([10, 60, 300])])_, también funciona: cada reenvío lleva el delay que corresponde a ese intento.
b) El efecto secundario: SQS nunca se entera del fallo
Cuando en handle() se lanza una excepción, el Worker de Laravel la atrapa en su propio try/catch: la reporta en los logs, decide si reintenta o da el job por perdido, y sigue de largo. Como esa excepción nunca llega hasta handleSqs(), para AWS Lambda la función terminó sin errores.
Esto significa que el mecanismo nativo de reintentos de SQS _(visibility timeout → maxReceiveCount → DLQ)_ jamás se entera de que un job falló por una excepción de PHP. Ni en el primer intento, ni en el tercero.
> _Laravel es ese compañero que en la daily dice "todo bien" con producción en llamas. Y SQS le cree._
c) Primera capa: cuando el job agota sus intentos en Laravel
Cuando un job falla por tercera vez y agota sus intentos, Laravel se rinde: ejecuta el método failed() de la clase y registra el fallo usando el driver configurado en config/queue.php _(queue.failed)_.
El problema es que, por defecto, Laravel intenta guardar ese registro en una tabla failed_jobs de una base de datos SQL... de la cual nos despedimos en el capítulo anterior. Sin base relacional, la salida rápida es desactivarlo completamente con QUEUE_FAILED_DRIVER: null [^3]: la excepción igual queda en los logs de CloudWatch, pero el job no se guarda en ningún lado. No lo recomiendo.
La buena noticia es que Laravel ya incluye soporte nativo para guardar failed_jobs en DynamoDB [^14]. Solo debemos ajustar config/queue.php:
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
// Al utilizar driver=dynamodb no es necesario especificar 'database'
// 'database' => env('DB_CONNECTION', 'sqlite'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'token' => env('AWS_SESSION_TOKEN'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_FAILED_JOBS_TABLE', 'failed_jobs'),
],
provider:
environment:
QUEUE_FAILED_DRIVER: dynamodb
DYNAMODB_FAILED_JOBS_TABLE: !Ref FailedJobsTable
Y declaramos la tabla dentro de resources en serverless.yml. Laravel usa application como partition key _(el nombre de la app)_ y uuid como sort key:
FailedJobsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: failed_jobs
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: application, AttributeType: S }
- { AttributeName: uuid, AttributeType: S }
KeySchema:
- { AttributeName: application, KeyType: HASH }
- { AttributeName: uuid, KeyType: RANGE }
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
> Así como lo hicimos en el capítulo anterior con la tabla de caché y sesiones, utilizamos TimeToLiveSpecification; Laravel guarda cada job fallido con fecha de vencimiento _(expires_at, 7 días por defecto [^14])_. Este driver ni siquiera implementa queue:flush: DynamoDB se encarga de limpiar los registros caducados.
Por último, le damos permisos sobre la tabla al rol de ejecución:
provider:
iam:
role:
statements:
- Effect: Allow
Resource: !GetAtt FailedJobsTable.Arn
Action:
- dynamodb:PutItem
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:DeleteItem
Para listar o reintentar los jobs fallidos, como en Lambda no hay SSH, usamos el puente bref:cli que vimos en el segundo capítulo:
serverless bref:cli --args="queue:failed"
serverless bref:cli --args="queue:retry all"
> _"Entonces, ¿para qué está la DLQ que creó Lift?"_
d) Segunda capa: cuando falla la infraestructura
Un mensaje solo llega a la _Dead Letter Queue_ cuando falla la invocación completa de Lambda, sin darle oportunidad a Laravel de atrapar el error. Por ejemplo:
- Un job superó el tiempo que le quedaba a la invocación (el pcntl_alarm se activó y la ejecución salió con exit(1)).
- Se quedó sin memoria _(OOM)_.
- Un error fatal de PHP fuera del alcance del Worker.
- Un fallo de arranque: una extensión faltante, un error de configuración, un composer install que dejó algo afuera.
En cualquiera de estos escenarios, Lambda muere antes de que Bref pueda borrar el mensaje, así que entra en acción el mecanismo nativo de SQS:
1. Espera (_visibility timeout_): el mensaje queda oculto hasta que vence su tiempo de visibilidad. Lift lo configura automáticamente en 6 veces el timeout del worker siguiendo la recomendación de AWS [^15] _(con los 60 segundos de nuestro ejemplo, reaparece a los 6 minutos)_.
2. Reintento: una vez visible de nuevo, SQS vuelve a entregárselo a Lambda.
3. Rumbo a la DLQ: si la invocación falla 3 veces seguidas _(maxRetries por defecto en Lift)_, SQS retira el mensaje de la cola principal y lo guarda en la Dead Letter Queue hasta por 14 días, evitando que se pierda.
Para inspeccionar qué cayó en la DLQ o volver a encolarlo sin entrar a la consola de AWS, Lift nos da estos comandos [^16]:
serverless jobs:logs --tail # logs del worker, en vivo
serverless jobs:failed # qué hay en la DLQ
serverless jobs:failed:retry # devolver todo a la cola principal
serverless jobs:failed:purge # borrar todo y seguir con la vida
Claro que nadie quiere ejecutar serverless jobs:failed todos los días por las dudas. Si agregan la propiedad alarm al construct, Lift configura automáticamente una alarma en CloudWatch que les envía un email apenas cae un mensaje en la DLQ [^4]:
constructs:
jobs:
type: queue
# ...
alarm: alertas@mi-dominio.com # alerta por email cuando la DLQ recibe mensajes
> Luego del primer serverless deploy, AWS les enviará un correo de confirmación al email configurado en alarm _(no olviden confirmarlo para activar las alertas)_.
e) Las dos capas, lado a lado
| | Primera capa: failed_jobs | Segunda capa: DLQ de SQS |
|-------------------------|-----------------------------------------|--------------------------------------------------|
| ¿Qué falló? | Nuestro código: excepción en handle() | La infraestructura: timeout, OOM, error fatal |
| ¿Quién reintenta? | Laravel (#[Tries], #[Backoff]) | SQS (_visibility timeout_, maxRetries) |
| ¿Qué se reintenta? | Ese job, tras el backoff configurado | El job en curso y el resto del lote (6× timeout) |
| ¿Dónde termina? | Tabla failed_jobs (DynamoDB, 7 días) | Dead Letter Queue (SQS, 14 días) |
| ¿Cómo lo reintento? | bref:cli --args="queue:retry ..." | serverless jobs:failed:retry |
| ¿Cómo me entero? | failed(), CloudWatch, queue:failed | Alerta por email (alarm:) |
Fíjense en la fila ¿Qué se reintenta? de la segunda capa. Cuando Lambda muere a mitad de un lote _(por timeout u OOM)_, los jobs que ya habían terminado fueron eliminados uno a uno con $job->delete(), así que no se repiten. SQS solo vuelve a entregar lo que quedó sin borrar: el job que estaba a mitad de ejecución y los que venían detrás en la fila.
El peligro está en ese job que quedó a medias: cuando SQS lo reintente, va a correr de nuevo desde el principio. Si ya había disparado un cobro o enviado un email un segundo antes de que muriera Lambda y no es idempotente, lo hará por segunda vez _(y van a aprender la palabra "reembolso" antes de lo que quisieran)_.
> Regla de oro: diseñen siempre jobs idempotentes.
---
6. Ajustando las Perillas del Worker para Producción
Las 8 líneas de la sección 3 alcanzan para arrancar, pero ahora que conocemos qué pasa debajo del capó podemos ajustar las perillas para producción. Así se ve una configuración pensada para el mundo real:
constructs:
jobs:
type: queue
maxRetries: 5 # maxReceiveCount de la cola: AWS recomienda >= 5
maxConcurrency: 20 # el freno "limpio": Lambda deja de leer, los mensajes esperan
worker:
handler: Bref\LaravelBridge\Queue\QueueHandler
runtime: php-85
layers:
- ${bref-extra:gd-php-85}
reservedConcurrency: 25 # tope duro y reserva; siempre >= maxConcurrency
timeout: 300
batchSize: 10
a) timeout: el techo del job más largo
Define tanto el tiempo máximo que puede durar una invocación como el _visibility timeout_ de la cola en SQS _(que Lift multiplica ×6)_. En mi caso lo llevé a 300 segundos porque el worker procesa imágenes pesadas, y redimensionar una foto de 20 MB lleva su tiempo. Si sus jobs solo envían emails o llaman a una API, con 30 o 60 segundos van sobrados.
b) batchSize: menos invocaciones, más responsabilidad
Lift lo deja en 1 por defecto para simplificar el manejo de errores [^17]. Subirlo a 10 significa que una ráfaga de 500 jobs se procesa en 50 invocaciones en lugar de 500, reduciendo _cold-starts_ y costos. A cambio, recuerden las dos condiciones que vimos antes: todos los jobs del lote comparten el mismo reloj de Lambda, y nuestros jobs deben ser idempotentes por si una invocación muere a mitad del lote.
> Existe también maxBatchingWindow: cuántos segundos espera SQS para juntar un lote completo antes de invocar. Cero por defecto. Si no les molesta que un job espere unos segundos a que lleguen compañeros, pueden subirlo.
c) reservedConcurrency vs maxConcurrency
Si llegan 1.000 jobs de golpe, Lambda escala rápido hasta cientos de invocaciones simultáneas. Y eso suena hermoso hasta que esos 1.000 jobs pegan al mismo tiempo contra una API externa con _rate limit_, contra nuestro servidor SMTP, o contra la misma DynamoDB.
Hay dos formas de ponerle un techo:
- reservedConcurrency _(propiedad de la función)_: la función nunca ejecuta más de N instancias simultáneas. Pero además reserva esas N de la cuota total de concurrencia de la cuenta, aunque estén sin uso [^18].
- maxConcurrency _(propiedad del construct de Lift)_: limita cuántas invocaciones puede disparar el event source mapping de esta cola, sin reservar nada [^19].
Lo ideal es combinarlos: maxConcurrency como freno limpio _(los mensajes esperan en la cola sin gastar intentos)_ y reservedConcurrency apenas por encima, como red de seguridad por si un bug despacha jobs en loop.
> Ojo: si usan solo reservedConcurrency, todo lo que supere ese límite cae en _throttling_, y cada rechazo cuenta como un intento en SQS. Con maxRetries: 3 _(el default de Lift)_, un pico de tráfico puede mandar jobs directo a la DLQ sin haberse ejecutado ni una vez. Por eso AWS recomienda subir maxRetries a por lo menos 5 [^20].
d) memorySize: más memoria también es más CPU
Como vimos en el segundo capítulo, en Lambda la potencia de CPU escala de forma proporcional a la memoria asignada. Un job pesado que tarda 10 segundos con 512 MB puede resolverse en 3 segundos con 1.769 MB; y como AWS cobra por GB-segundo, darle más memoria muchas veces termina saliendo más barato. Si su worker hace trabajo intensivo, pueden asignarle su propio memorySize y medir el punto dulce con aws-lambda-power-tuning [^21].
e) Layers a medida: extensiones solo donde hacen falta
¿Notaron el layer ${bref-extra:gd-php-85} en la configuración? Mi worker necesita la extensión GD para procesar imágenes, pero la función HTTP web no. Al ser funciones Lambda separadas, cada una carga únicamente las extensiones que usa: las peticiones web siguen livianas, y el peso extra solo lo carga el worker en segundo plano.
f) ¿Hace falta un warmer en el worker?
En el segundo capítulo implementamos un _warmer_ cada 5 minutos para evitar el _cold-start_ en las peticiones web. ¿Necesita uno el worker? Para nada. Un _cold-start_ de 200 ms en una ruta HTTP lo sufre un usuario mirando cómo carga la pantalla; en una cola en segundo plano, 200 ms extra no le quitan el sueño a nadie.
---
7. Lo que No Te Cuentan los Tutoriales (y Descubres en Producción)
Cosas que no están en la documentación _(o que estaban en letra chica y aprendí por las malas)_:
a) SerializesModels y las claves compuestas de DynamoDB
¿Notaron que en SendWelcomeEmail _(sección 5.a)_ pasé $user_uuid como string en lugar del modelo User? No fue casualidad. SerializesModels guarda solo getKey() _(PK)_ e intenta restaurar el modelo con una consulta SQL tradicional que no incluye la SK ni soporta DynamoDB.
Para sus propios jobs, lo más simple es pasar el uuid al constructor y buscar el modelo dentro de handle(). Ahora bien, si usan notificaciones encoladas _($user->notify())_, Laravel serializa el modelo User sí o sí: ahí no queda otra que sobreescribir getQueueableId() y newQueryForRestoration() en su modelo para codificar y reconstruir PK + SK.
b) Bus::batch() también necesita dónde guardar su progreso
Por defecto busca la tabla relacional job_batches, pero Laravel incluye soporte nativo para DynamoDB: solo deben crear la tabla job_batches _(partition key application, sort key id)_, darle permisos IAM y configurar queue.batching.driver en dynamodb [^22].
c) El entorno local no necesita tocar AWS
Como el código de nuestros jobs no cambia según el driver, en su máquina pueden seguir usando QUEUE_CONNECTION=database _(o sync)_ con el queue:work de toda la vida, o bien emular SQS localmente con herramientas como MiniStack [^23] o LocalStack [^24]. QUEUE_CONNECTION=sqs apuntando a AWS solo hace falta en serverless.yml.
d) El límite es de 1 MiB por mensaje
Es el tope de SQS [^25]. Los jobs deben transportar identificadores livianos, no archivos en base64 ni colecciones de 10.000 elementos. Si están cerca de ese límite, el diseño les está pidiendo a gritos que el job busque los datos cuando corre en vez de recibirlos en el payload.
e) La limpieza del SDK de AWS del segundo capítulo
Si usaron aws/aws-sdk-php con removeUnusedServices en composer.json para reducir el peso del deploy, asegúrense de agregar Sqs a la lista de servicios permitidos:
"extra": {
"aws/aws-sdk-php": [
"DynamoDb",
"S3",
"Sqs"
]
}
¿Por qué es tan fácil olvidarlo? Porque los tests corren con QUEUE_CONNECTION=sync o Queue::fake(), así que el CI pasa todo en verde sin instanciar jamás SqsClient... hasta que el primer dispatch() real en producción explota porque la clase fue borrada.
---
8. Costos: El Worker que Solo Cobra Cuando Trabaja
Actualicemos la cuenta, como en cada capítulo.
- Antes (EC2): el worker vivía en una de las instancias t3.small del primer capítulo _(~$15/mes por instancia)_. Un proceso queue:work residente con RAM reservada y supervisord encendido las 24 horas, aunque la cola pasara el 90% del día vacía.
- Ahora (SQS + Lambda):
- SQS: el primer millón de requests mensuales es gratis; después cuesta \$0.40 por millón [^26]. Sumando los ~630.000 requests del polling en reposo y 3 requests por cada job _(SendMessage, ReceiveMessage y DeleteMessage)_, pueden procesar hasta ~120.000 jobs al mes por $0.00.
- Lambda: la capa gratuita perpetua incluye 1 millón de invocaciones y 400.000 GB-segundos al mes. Sin jobs en la cola, el cómputo cuesta exactamente cero.
Pongámoslo en números para un mes con 100.000 jobs _(de ~500 ms promedio con 512 MB de RAM)_:
| Concepto | Dentro del Free Tier | Tarifa Plena _(sin Free Tier)_ |
|-----------------------------|-------------------------:|-----------------------------------:|
| Polling SQS (~630k req/mes) | $0.00 | $0.25 |
| Mensajes SQS (300k req/mes) | $0.00 | $0.12 |
| Lambda Worker (100k jobs) | $0.00 | $0.44 |
| Total mensual | $0.00 | ~$0.81 |
> _¡Incluso sin la capa gratuita, procesar 100.000 jobs cuesta menos de un dólar al mes!_
Y lo más importante: con DynamoDB en PAY_PER_REQUEST, Lambda facturando por milisegundo y SQS por request, casi ningún componente del sistema de colas cobra tarifa fija por existir _(a excepción del polling de SQS, que en mi caso cae dentro del free tier)_.
---
9. ¿Qué Sigue?
Hoy jubilamos definitivamente a supervisord y al proceso queue:work que corría las 24 horas: los reemplazamos por una cola SQS y un worker en Lambda que solo despierta cuando hay trabajo. Vimos que Bref reutiliza el mismo Worker de Laravel _(así que nuestros jobs no cambian)_, cómo hace su truco en release() para conservar los reintentos y el backoff, y cómo conviven failed_jobs en DynamoDB con la _Dead Letter Queue_ de SQS.
Pero quedó un cabo suelto entre el capítulo anterior y este. En la sección 6 les conté que mi worker carga la extensión GD para redimensionar imágenes; ahora bien, si en el capítulo anterior vimos que los archivos viajan directo del navegador a S3 sin pasar por nuestra aplicación... ¿quién le avisa a Laravel que terminó de subirse un archivo para encolar ese job?
En el próximo capítulo vamos a cerrar ese círculo con S3 Event Notifications, y veremos por qué la solución _no_ es conectar S3 directo a la cola que acabamos de crear.
---
Una pregunta para ustedes: ¿con qué corren sus colas de Laravel hoy en producción, o cuál fue la peor jugada que les hizo un worker a las 3 AM? Los leo en los comentarios: las mejores historias de producción salen de ahí, no de los post-mortems.
[^1]: docs.aws.amazon.com
[^2]: docs.aws.amazon.com
[^3]: bref.sh
[^4]: github.com/getlift/lift/blob/master/docs/queue.md
[^5]: github.com/getlift/lift/blob/master/docs/permissi…
[^6]: docs.aws.amazon.com
[^7]: bref.sh
[^8]: docs.aws.amazon.com
[^9]: aws.amazon.com
[^10]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^11]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^12]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^13]: github.com/brefphp/laravel-bridge/blob/a62196d5cb…
[^14]: laravel.com
[^15]: github.com/getlift/lift/blob/master/docs/queue.md
[^16]: github.com/getlift/lift/blob/master/docs/queue.md
[^17]: github.com/getlift/lift/blob/master/docs/queue.md
[^18]: docs.aws.amazon.com
[^19]: github.com/getlift/lift/blob/master/docs/queue.md
[^20]: docs.aws.amazon.com
[^21]: alexcasalboni/aws-lambda-power-tuning
[^22]: laravel.com
[^23]: ministackorg/ministack
[^24]: localstack/localstack
[^25]: docs.aws.amazon.com
[^26]: aws.amazon.com
Trying MiniStack and StackPort
Introduction
I tried MiniStack, a free local AWS emulator, and StackPort, a browser-based viewer for its resources.
{% embed ministackorg/ministack %}
{% embed DaviReisVieira/stackport %}
This article builds the following pipeline:
- EventBridge Scheduler invokes a Lambda function on a schedule
- The Lambda function calls an API and publishes the result to SNS
- SNS triggers a second Lambda function, which posts to a Slack Incoming Webhook
- The API key and the Webhook URL are stored as SecureString parameters in SSM Parameter Store
What Are MiniStack and StackPort?
MiniStack
MiniStack is a local AWS emulator under the MIT license. You can use more than 60 AWS services directly from the AWS CLI, boto3, Terraform, and CDK.
{% embed ministack.org %}
StackPort
StackPort is a tool for browsing the resources of local emulators and real AWS accounts in a browser. It connects to emulators other than MiniStack (such as LocalStack) and to real AWS accounts.
{% embed stackport.cloud %}
StackPort lets you browse the resources of 35 services.
Eight of them have dedicated screens: S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager. For example, CloudWatch Logs lets you drill down from log groups to log streams to log events.
The other services (such as SSM in this article) are shown as a searchable resource table with a JSON detail view.
Note that StackPort does not support EventBridge Scheduler. The events service only shows EventBridge rules and event buses, so I check the schedule with the CLI.
Architecture
Locally, I run a mock server (mock-api) in place of the external API. For Slack, I use a real Incoming Webhook, with separate channels for the local and AWS environments.
!Local environment architecture
Two things differ between the local environment and real AWS:
- External API: mock-api locally, API Gateway (MOCK integration) on real AWS. Both return GET /v1/status
- Slack destination: #notify-demo-local locally, #notify-demo-aws on real AWS
The directory structure is as follows. Terraform is split into a shared module (modules/notify) and one directory per environment (environments/local and environments/aws). Each environment has its own state.
code/
├── lambda/ # Shared: the same code runs locally and on real AWS
│ ├── fetch_status/
│ │ └── app.py
│ └── notify_slack/
│ └── app.py
├── mock-api/ # Local only: mock server standing in for the external API
│ ├── Dockerfile
│ └── server.py
├── scripts/
│ ├── put-parameters-aws.sh # AWS only: registers parameters in the real SSM
│ └── put-parameters.sh # Local only: registers parameters in MiniStack's SSM
├── terraform/
│ ├── environments/
│ │ ├── aws/ # For real AWS: points at real AWS
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ ├── providers.tf
│ │ │ ├── terraform.tfvars
│ │ │ ├── terraform.tfvars.example
│ │ │ └── variables.tf
│ │ └── local/ # For local: points at MiniStack
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ ├── providers.tf
│ │ ├── terraform.tfvars
│ │ ├── terraform.tfvars.example
│ │ └── variables.tf
│ └── modules/
│ └── notify/ # Shared: Lambda, SNS, Scheduler, IAM, and more
│ ├── main.tf
│ ├── outputs.tf
│ ├── variables.tf
│ └── versions.tf
└── docker-compose.yml # Local only: starts MiniStack, StackPort, and mock-api
Building the Local Environment
You need Docker (Docker Desktop, OrbStack, and so on), Terraform, and the AWS CLI.
Step 1: Start MiniStack, StackPort, and the Mock Server
Start three containers with docker-compose.yml.
services:
ministack:
image: ministackorg/ministack:latest
ports:
- "4566:4566"
environment:
- MINISTACK_REGION=ap-northeast-1
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')",
]
interval: 5s
timeout: 3s
retries: 5
stackport:
image: davireis/stackport:latest
ports:
- "8080:8080"
environment:
- AWS_ENDPOINT_URL=http://ministack:4566
- AWS_REGION=ap-northeast-1
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')",
]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
depends_on:
ministack:
condition: service_healthy
# Mock server standing in for the external API
mock-api:
build: ./mock-api
ports:
- "8000:8000"
environment:
- EXPECTED_API_KEY=local-dummy-api-key
The three containers are:
- ministack: the local AWS emulator. It accepts AWS API calls on port 4566
- stackport: a web UI for browsing MiniStack's resources. It opens on port 8080
- mock-api: a mock server standing in for the external API. It runs on port 8000
mock-api is written with only the Python standard library. It has one endpoint that validates the x-api-key header and returns a status.
def do_GET(self):
if self.path == "/v1/status":
if self.headers.get("x-api-key") != EXPECTED_API_KEY:
return self._send(403, {"message": "Forbidden"})
return self._send(200, {"status": "ok"})
return self._send(404, {"message": "not found"})
Start the containers.
cd code
docker compose up -d --build
Open http://localhost:8080 in a browser to see the StackPort dashboard.
!StackPort dashboard
Step 2: Register SecureString Parameters in SSM Parameter Store
I register the API key and the Webhook URL outside Terraform.
If you create them as aws_ssm_parameter resources in Terraform, the values remain in the state file in plain text. Write-only arguments (value_wo) in Terraform 1.11 and later keep them out of the state, but I don't use them here.
I prepared a script (scripts/put-parameters.sh) to register them.
#!/usr/bin/env bash
set -euo pipefail
: "${SLACK_WEBHOOK_URL:?Set SLACK_WEBHOOK_URL}"
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=ap-northeast-1
ENDPOINT=http://localhost:4566
aws --endpoint-url="$ENDPOINT" ssm put-parameter \
--name /notify-demo/api-key \
--type SecureString \
--value local-dummy-api-key \
--overwrite
aws --endpoint-url="$ENDPOINT" ssm put-parameter \
--name /notify-demo/slack-webhook-url \
--type SecureString \
--value "$SLACK_WEBHOOK_URL" \
--overwrite
For the Webhook URL, I use an Incoming Webhook issued for a Slack channel dedicated to local testing (#notify-demo-local).
SLACK_WEBHOOK_URL=<Webhook URL of the local channel> ./scripts/put-parameters.sh
Running the script adds the parameters.
!StackPort SSM parameter list
The displayed value is ENCRYPTED: followed by a base64 string. It is not encrypted with KMS, and anyone can decode it back to the original value, so be careful when sharing screenshots.
!A SecureString value as seen in StackPort
Step 3: Write the Lambda Code
This is the Lambda function that calls the API (fetch_status). It gets the API key from SSM and publishes the API response to SNS.
import json
import os
import urllib.request
import boto3
ssm = boto3.client("ssm")
sns = boto3.client("sns")
API_BASE_URL = os.environ["API_BASE_URL"]
API_KEY_PARAM = os.environ["API_KEY_PARAM"]
RESULT_TOPIC_ARN = os.environ["RESULT_TOPIC_ARN"]
_api_key = None
def get_api_key():
# Fetch from SSM only on a cold start; use the cache afterwards
global _api_key
if _api_key is None:
res = ssm.get_parameter(Name=API_KEY_PARAM, WithDecryption=True)
_api_key = res["Parameter"]["Value"]
return _api_key
def handler(event, context):
req = urllib.request.Request(
f"{API_BASE_URL}/v1/status",
headers={"x-api-key": get_api_key()},
)
# 4xx/5xx raise HTTPError, which makes the Lambda invocation fail
with urllib.request.urlopen(req, timeout=10) as res:
result = json.loads(res.read())
print(f"API status: {result['status']}")
sns.publish(
TopicArn=RESULT_TOPIC_ARN,
Subject="API status check",
Message=json.dumps(result, ensure_ascii=False),
)
return result
This is the Lambda function for Slack notifications (notify_slack). It extracts the SNS message and posts it to the Webhook.
def handler(event, context):
for record in event["Records"]:
result = json.loads(record["Sns"]["Message"])
now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
text = f":white_check_mark: API status: {result['status']} ({now})"
req = urllib.request.Request(
get_webhook_url(),
data=json.dumps({"text": text}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10):
pass
print(text)
Neither piece of code contains any MiniStack-specific endpoint configuration. MiniStack injects AWS_ENDPOINT_URL into the Lambda execution environment, so boto3 automatically points at MiniStack. The same code can be deployed to real AWS as is.
Step 4: Create the Resources with Terraform
The resources live in the shared module (terraform/modules/notify/main.tf). I don't show every resource. Instead, I excerpt the points I designed to work on both MiniStack and real AWS.
The external API URL is passed through the module variable api_base_url.
resource "aws_lambda_function" "fetch_status" {
function_name = local.fetch_name
role = aws_iam_role.fetch_status.arn
runtime = "python3.14"
handler = "app.handler"
filename = data.archive_file.fetch_status.output_path
source_code_hash = data.archive_file.fetch_status.output_base64sha256
timeout = 30
environment {
variables = {
API_BASE_URL = var.api_base_url
API_KEY_PARAM = local.api_key_param
RESULT_TOPIC_ARN = aws_sns_topic.result.arn
}
}
depends_on = [aws_cloudwatch_log_group.fetch_status]
}
terraform/environments/ contains one directory per environment (local and aws). Each has a main.tf that calls the shared module and its own environment-specific settings.
The only differences are providers.tf, which decides the connection target, and terraform.tfvars, which decides the external API URL.
Because the directories are separate, the state is also separate for each environment.
In terraform/environments/local/providers.tf, the provider points only the services I use at MiniStack.
provider "aws" {
access_key = "test"
secret_key = "test"
region = "ap-northeast-1"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints {
iam = "http://localhost:4566"
lambda = "http://localhost:4566"
logs = "http://localhost:4566"
scheduler = "http://localhost:4566"
sns = "http://localhost:4566"
ssm = "http://localhost:4566"
sts = "http://localhost:4566"
}
}
These are the settings that make it work with MiniStack:
- access_key, secret_key: dummy values (test)
- region: the region to deploy to
- skip_credentials_validation: skips validating credentials through the STS API
- skip_metadata_api_check: skips the check against the AWS metadata API
- skip_requesting_account_id: skips retrieving the account ID
- endpoints: points each service at MiniStack. Specify only the services you use
The local directory (terraform/environments/local/main.tf) calls the module. The external API URL is passed through the variable api_base_url.
module "notify" {
source = "../../modules/notify"
region = "ap-northeast-1"
api_base_url = var.api_base_url
}
The variable's value goes in terraform/environments/local/terraform.tfvars. Because this file is not managed in Git, create it by copying the template (terraform.tfvars.example). The Lambda function runs inside the MiniStack container, so it reaches mock-api by its Compose service name.
api_base_url = "http://mock-api:8000"
Apply it.
cd terraform/environments/local
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars if needed
terraform init
terraform apply
Here is the result. The resources are created in MiniStack's default account ID (000000000000).
$ terraform apply
module.notify.data.archive_file.notify_slack: Reading...
module.notify.data.archive_file.fetch_status: Reading...
module.notify.data.archive_file.notify_slack: Read complete after 0s [id=68fb7c506afbeeab169fb65494efc63dd8acba90]
module.notify.data.archive_file.fetch_status: Read complete after 0s [id=013b111cb8285884b651e501fd6f84ad373f787a]
module.notify.data.aws_iam_policy_document.lambda_assume: Reading...
module.notify.data.aws_caller_identity.current: Reading...
module.notify.data.aws_iam_policy_document.lambda_assume: Read complete after 0s [id=2690255455]
module.notify.data.aws_caller_identity.current: Read complete after 0s [id=000000000000]
module.notify.data.aws_iam_policy_document.scheduler_assume: Reading...
module.notify.data.aws_iam_policy_document.notify_slack: Reading...
module.notify.data.aws_iam_policy_document.scheduler_assume: Read complete after 0s [id=644537899]
module.notify.data.aws_iam_policy_document.notify_slack: Read complete after 0s [id=2364181876]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
# module.notify.data.aws_iam_policy_document.fetch_status will be read during apply
# (config refers to values not yet known)
<= data "aws_iam_policy_document" "fetch_status" {
+ id = (known after apply)
+ json = (known after apply)
+ minified_json = (known after apply)
+ statement {
+ actions = [
+ "ssm:GetParameter",
]
+ resources = [
+ "arn:aws:ssm:ap-northeast-1:000000000000:parameter/notify-demo/api-key",
]
}
+ statement {
+ actions = [
+ "sns:Publish",
]
+ resources = [
+ (known after apply),
]
}
}
# module.notify.data.aws_iam_policy_document.scheduler will be read during apply
# (config refers to values not yet known)
<= data "aws_iam_policy_document" "scheduler" {
+ id = (known after apply)
+ json = (known after apply)
+ minified_json = (known after apply)
+ statement {
+ actions = [
+ "lambda:InvokeFunction",
]
+ resources = [
+ (known after apply),
]
}
}
# module.notify.aws_cloudwatch_log_group.fetch_status will be created
+ resource "aws_cloudwatch_log_group" "fetch_status" {
+ arn = (known after apply)
+ deletion_protection_enabled = (known after apply)
+ id = (known after apply)
+ log_group_class = (known after apply)
+ name = "/aws/lambda/notify-demo-fetch-status"
+ name_prefix = (known after apply)
+ region = "ap-northeast-1"
+ retention_in_days = 7
+ skip_destroy = false
+ tags_all = (known after apply)
}
# module.notify.aws_cloudwatch_log_group.notify_slack will be created
+ resource "aws_cloudwatch_log_group" "notify_slack" {
+ arn = (known after apply)
+ deletion_protection_enabled = (known after apply)
+ id = (known after apply)
+ log_group_class = (known after apply)
+ name = "/aws/lambda/notify-demo-notify-slack"
+ name_prefix = (known after apply)
+ region = "ap-northeast-1"
+ retention_in_days = 7
+ skip_destroy = false
+ tags_all = (known after apply)
}
# module.notify.aws_iam_role.fetch_status will be created
+ resource "aws_iam_role" "fetch_status" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "lambda.amazonaws.com"
}
},
]
+ Version = "2012-10-17"
}
)
+ create_date = (known after apply)
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "notify-demo-fetch-status-role"
+ name_prefix = (known after apply)
+ path = "/"
+ tags_all = (known after apply)
+ unique_id = (known after apply)
+ inline_policy (known after apply)
}
# module.notify.aws_iam_role.notify_slack will be created
+ resource "aws_iam_role" "notify_slack" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "lambda.amazonaws.com"
}
},
]
+ Version = "2012-10-17"
}
)
+ create_date = (known after apply)
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "notify-demo-notify-slack-role"
+ name_prefix = (known after apply)
+ path = "/"
+ tags_all = (known after apply)
+ unique_id = (known after apply)
+ inline_policy (known after apply)
}
# module.notify.aws_iam_role.scheduler will be created
+ resource "aws_iam_role" "scheduler" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRole"
+ Condition = {
+ StringEquals = {
+ "aws:SourceAccount" = "000000000000"
}
}
+ Effect = "Allow"
+ Principal = {
+ Service = "scheduler.amazonaws.com"
}
},
]
+ Version = "2012-10-17"
}
)
+ create_date = (known after apply)
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "notify-demo-scheduler-role"
+ name_prefix = (known after apply)
+ path = "/"
+ tags_all = (known after apply)
+ unique_id = (known after apply)
+ inline_policy (known after apply)
}
# module.notify.aws_iam_role_policy.fetch_status will be created
+ resource "aws_iam_role_policy" "fetch_status" {
+ id = (known after apply)
+ name = (known after apply)
+ name_prefix = (known after apply)
+ policy = (known after apply)
+ role = (known after apply)
}
# module.notify.aws_iam_role_policy.notify_slack will be created
+ resource "aws_iam_role_policy" "notify_slack" {
+ id = (known after apply)
+ name = (known after apply)
+ name_prefix = (known after apply)
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "ssm:GetParameter"
+ Effect = "Allow"
+ Resource = "arn:aws:ssm:ap-northeast-1:000000000000:parameter/notify-demo/slack-webhook-url"
},
]
+ Version = "2012-10-17"
}
)
+ role = (known after apply)
}
# module.notify.aws_iam_role_policy.scheduler will be created
+ resource "aws_iam_role_policy" "scheduler" {
+ id = (known after apply)
+ name = (known after apply)
+ name_prefix = (known after apply)
+ policy = (known after apply)
+ role = (known after apply)
}
# module.notify.aws_iam_role_policy_attachment.fetch_status_logs will be created
+ resource "aws_iam_role_policy_attachment" "fetch_status_logs" {
+ id = (known after apply)
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ role = "notify-demo-fetch-status-role"
}
# module.notify.aws_iam_role_policy_attachment.notify_slack_logs will be created
+ resource "aws_iam_role_policy_attachment" "notify_slack_logs" {
+ id = (known after apply)
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ role = "notify-demo-notify-slack-role"
}
# module.notify.aws_lambda_function.fetch_status will be created
+ resource "aws_lambda_function" "fetch_status" {
+ architectures = (known after apply)
+ arn = (known after apply)
+ code_sha256 = (known after apply)
+ filename = "./build/fetch_status.zip"
+ function_name = "notify-demo-fetch-status"
+ handler = "app.handler"
+ id = (known after apply)
+ invoke_arn = (known after apply)
+ last_modified = (known after apply)
+ memory_size = 128
+ package_type = "Zip"
+ publish = false
+ qualified_arn = (known after apply)
+ qualified_invoke_arn = (known after apply)
+ region = "ap-northeast-1"
+ reserved_concurrent_executions = -1
+ response_streaming_invoke_arn = (known after apply)
+ role = (known after apply)
+ runtime = "python3.14"
+ signing_job_arn = (known after apply)
+ signing_profile_version_arn = (known after apply)
+ skip_destroy = false
+ source_code_hash = "So5oKi3b/bEDekVIzzKxoKwo1aO2n/JdG+Ioj2nhtVw="
+ source_code_size = (known after apply)
+ tags_all = (known after apply)
+ timeout = 30
+ version = (known after apply)
+ environment {
+ variables = (known after apply)
}
+ ephemeral_storage (known after apply)
+ logging_config (known after apply)
+ tracing_config (known after apply)
}
# module.notify.aws_lambda_function.notify_slack will be created
+ resource "aws_lambda_function" "notify_slack" {
+ architectures = (known after apply)
+ arn = (known after apply)
+ code_sha256 = (known after apply)
+ filename = "./build/notify_slack.zip"
+ function_name = "notify-demo-notify-slack"
+ handler = "app.handler"
+ id = (known after apply)
+ invoke_arn = (known after apply)
+ last_modified = (known after apply)
+ memory_size = 128
+ package_type = "Zip"
+ publish = false
+ qualified_arn = (known after apply)
+ qualified_invoke_arn = (known after apply)
+ region = "ap-northeast-1"
+ reserved_concurrent_executions = -1
+ response_streaming_invoke_arn = (known after apply)
+ role = (known after apply)
+ runtime = "python3.14"
+ signing_job_arn = (known after apply)
+ signing_profile_version_arn = (known after apply)
+ skip_destroy = false
+ source_code_hash = "M+ENHhkFRJqThFhPFiREbrEpiqqEf0cJLzdvTNzrMUY="
+ source_code_size = (known after apply)
+ tags_all = (known after apply)
+ timeout = 30
+ version = (known after apply)
+ environment {
+ variables = {
+ "TZ" = "Asia/Tokyo"
+ "WEBHOOK_URL_PARAM" = "/notify-demo/slack-webhook-url"
}
}
+ ephemeral_storage (known after apply)
+ logging_config (known after apply)
+ tracing_config (known after apply)
}
# module.notify.aws_lambda_permission.notify_slack_from_sns will be created
+ resource "aws_lambda_permission" "notify_slack_from_sns" {
+ action = "lambda:InvokeFunction"
+ function_name = "notify-demo-notify-slack"
+ id = (known after apply)
+ principal = "sns.amazonaws.com"
+ region = "ap-northeast-1"
+ source_arn = (known after apply)
+ statement_id = "AllowExecutionFromSNS"
+ statement_id_prefix = (known after apply)
}
# module.notify.aws_scheduler_schedule.fetch_status will be created
+ resource "aws_scheduler_schedule" "fetch_status" {
+ action_after_completion = (known after apply)
+ arn = (known after apply)
+ group_name = (known after apply)
+ id = (known after apply)
+ name = "notify-demo-fetch-status"
+ name_prefix = (known after apply)
+ region = "ap-northeast-1"
+ schedule_expression = "rate(1 minute)"
+ schedule_expression_timezone = "UTC"
+ state = "ENABLED"
+ flexible_time_window {
+ mode = "OFF"
}
+ target {
+ arn = (known after apply)
+ input = jsonencode(
{
+ source = "scheduler"
}
)
+ role_arn = (known after apply)
}
}
# module.notify.aws_sns_topic.result will be created
+ resource "aws_sns_topic" "result" {
+ arn = (known after apply)
+ beginning_archive_time = (known after apply)
+ content_based_deduplication = false
+ fifo_throughput_scope = (known after apply)
+ fifo_topic = false
+ id = (known after apply)
+ maximum_message_size = (known after apply)
+ name = "notify-demo-result"
+ name_prefix = (known after apply)
+ owner = (known after apply)
+ policy = (known after apply)
+ region = "ap-northeast-1"
+ signature_version = (known after apply)
+ tags_all = (known after apply)
+ tracing_config = (known after apply)
}
# module.notify.aws_sns_topic_subscription.result_to_notify_slack will be created
+ resource "aws_sns_topic_subscription" "result_to_notify_slack" {
+ arn = (known after apply)
+ confirmation_timeout_in_minutes = 1
+ confirmation_was_authenticated = (known after apply)
+ endpoint = (known after apply)
+ endpoint_auto_confirms = false
+ filter_policy_scope = (known after apply)
+ id = (known after apply)
+ owner_id = (known after apply)
+ pending_confirmation = (known after apply)
+ protocol = "lambda"
+ raw_message_delivery = false
+ region = "ap-northeast-1"
+ topic_arn = (known after apply)
}
Plan: 16 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ fetch_status_function_name = "notify-demo-fetch-status"
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
module.notify.aws_cloudwatch_log_group.notify_slack: Creating...
module.notify.aws_cloudwatch_log_group.fetch_status: Creating...
module.notify.aws_iam_role.fetch_status: Creating...
module.notify.aws_iam_role.scheduler: Creating...
module.notify.aws_sns_topic.result: Creating...
module.notify.aws_iam_role.notify_slack: Creating...
module.notify.aws_iam_role.fetch_status: Creation complete after 0s [id=notify-demo-fetch-status-role]
module.notify.aws_iam_role.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role]
module.notify.aws_iam_role.notify_slack: Creation complete after 0s [id=notify-demo-notify-slack-role]
module.notify.aws_cloudwatch_log_group.fetch_status: Creation complete after 0s [id=/aws/lambda/notify-demo-fetch-status]
module.notify.aws_cloudwatch_log_group.notify_slack: Creation complete after 0s [id=/aws/lambda/notify-demo-notify-slack]
module.notify.aws_sns_topic.result: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:000000000000:notify-demo-result]
module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creating...
module.notify.data.aws_iam_policy_document.fetch_status: Reading...
module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creating...
module.notify.aws_iam_role_policy.notify_slack: Creating...
module.notify.data.aws_iam_policy_document.fetch_status: Read complete after 0s [id=193309822]
module.notify.aws_lambda_function.notify_slack: Creating...
module.notify.aws_lambda_function.fetch_status: Creating...
module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creation complete after 0s [id=notify-demo-fetch-status-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
module.notify.aws_iam_role_policy.notify_slack: Creation complete after 0s [id=notify-demo-notify-slack-role:terraform-0c334b43e9f77ddf68c66755f5]
module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creation complete after 0s [id=notify-demo-notify-slack-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
module.notify.aws_iam_role_policy.fetch_status: Creating...
module.notify.aws_iam_role_policy.fetch_status: Creation complete after 0s [id=notify-demo-fetch-status-role:terraform-5e557969d0acd65274a217f306]
module.notify.aws_lambda_function.notify_slack: Creation complete after 5s [id=notify-demo-notify-slack]
module.notify.aws_lambda_permission.notify_slack_from_sns: Creating...
module.notify.aws_lambda_permission.notify_slack_from_sns: Creation complete after 0s [id=AllowExecutionFromSNS]
module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creating...
module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:000000000000:notify-demo-result:8876cdbc-24f6-49f7-b09e-14fb507ff76e]
module.notify.aws_lambda_function.fetch_status: Still creating... [00m10s elapsed]
module.notify.aws_lambda_function.fetch_status: Creation complete after 10s [id=notify-demo-fetch-status]
module.notify.data.aws_iam_policy_document.scheduler: Reading...
module.notify.data.aws_iam_policy_document.scheduler: Read complete after 0s [id=1455079044]
module.notify.aws_scheduler_schedule.fetch_status: Creating...
module.notify.aws_iam_role_policy.scheduler: Creating...
module.notify.aws_scheduler_schedule.fetch_status: Creation complete after 0s [id=default/notify-demo-fetch-status]
module.notify.aws_iam_role_policy.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role:terraform-db943e5a1f8c21b3a1a29f7c69]
Apply complete! Resources: 16 added, 0 changed, 0 destroyed.
Outputs:
fetch_status_function_name = "notify-demo-fetch-status"
Two functions appear on the StackPort Lambda screen.
!StackPort Lambda function list
StackPort does not show schedules, so I check them with the CLI.
$ aws --endpoint-url=http://localhost:4566 --region ap-northeast-1 \
> scheduler list-schedules
{
"Schedules": [
{
"Arn": "arn:aws:scheduler:ap-northeast-1:000000000000:schedule/default/notify-demo-fetch-status",
"CreationDate": "2026-10-12T09:56:36.343454+09:00",
"GroupName": "default",
"LastModificationDate": "2026-10-12T09:56:36.343454+09:00",
"Name": "notify-demo-fetch-status",
"State": "ENABLED",
"Target": {
"Arn": "arn:aws:lambda:ap-northeast-1:000000000000:function:notify-demo-fetch-status"
}
}
]
}
Step 5: Verify the Behavior
After a short wait, API status: ok is posted to #notify-demo-local.
!Notification posted to Slack from the local environment
You can check the Lambda execution logs on the StackPort CloudWatch Logs screen. First, the log group list shows /aws/lambda/notify-demo-notify-slack and /aws/lambda/notify-demo-fetch-status.
!StackPort log group list
Opening /aws/lambda/notify-demo-notify-slack shows a list of log streams, one per execution.
!StackPort log stream list
Opening a log stream shows START, END, and REPORT, plus what the Lambda function printed: the same API status: ok (…) that went to Slack.
!StackPort log events
Deploying to Real AWS and Verifying the Same Behavior
I deploy the pipeline I verified locally to a real AWS account and check that it behaves the same way.
The Lambda code and the Terraform module are shared with the local environment. Only the environment directory (environments/aws) and the values registered in SSM change.
!Real AWS architecture
The thick red borders show what differs from the local environment. An API Gateway created in advance stands in for the external API, and Slack notifications go to the AWS channel (#notify-demo-aws).
Step 1: Register the SSM Parameters
The API key was generated by the API Gateway. I retrieve its value and register it in SSM as a SecureString, together with the Incoming Webhook URL of the AWS channel.
Apart from retrieving the API key, the script is the same as the local one, so I omit its contents.
API_KEY_ID=<API key ID> SLACK_WEBHOOK_URL=<Webhook URL of the AWS channel> ./scripts/put-parameters-aws.sh
This is the Parameter Store in the AWS console after registration.
!AWS SSM parameter list
Opening a parameter shows its SecureString value as ********. Turn on "Show decrypted value" to see the value.
!AWS SSM parameter details
Step 2: Deploy
terraform/environments/aws/providers.tf has no MiniStack-specific settings.
provider "aws" {
region = "ap-northeast-1"
}
The module call (terraform/environments/aws/main.tf) is the same as the local one.
module "notify" {
source = "../../modules/notify"
region = "ap-northeast-1"
api_base_url = var.api_base_url
}
Only the external API URL changes, in terraform/environments/aws/terraform.tfvars. This file is not managed in Git either, so create it by copying the template (terraform.tfvars.example) and rewrite it with the URL of the API Gateway you created in advance.
api_base_url = "https://<API ID>.execute-api.ap-northeast-1.amazonaws.com/prod"
Apply it.
cd terraform/environments/aws
cp terraform.tfvars.example terraform.tfvars
# Rewrite <API ID> in terraform.tfvars
terraform init
terraform apply
For comparison with the apply against MiniStack, here is the result. The account ID is masked.
$ terraform apply
module.notify.data.archive_file.fetch_status: Reading...
module.notify.data.archive_file.notify_slack: Reading...
module.notify.data.archive_file.notify_slack: Read complete after 0s [id=68fb7c506afbeeab169fb65494efc63dd8acba90]
module.notify.data.archive_file.fetch_status: Read complete after 0s [id=013b111cb8285884b651e501fd6f84ad373f787a]
module.notify.data.aws_iam_policy_document.lambda_assume: Reading...
module.notify.data.aws_caller_identity.current: Reading...
module.notify.data.aws_iam_policy_document.lambda_assume: Read complete after 0s [id=2690255455]
module.notify.data.aws_caller_identity.current: Read complete after 0s [id=123456789012]
module.notify.data.aws_iam_policy_document.scheduler_assume: Reading...
module.notify.data.aws_iam_policy_document.notify_slack: Reading...
module.notify.data.aws_iam_policy_document.notify_slack: Read complete after 0s [id=3754217979]
module.notify.data.aws_iam_policy_document.scheduler_assume: Read complete after 0s [id=991516426]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
- create
<= read (data resources)
Terraform will perform the following actions:
module.notify.data.aws_iam_policy_document.fetch_status will be read during apply
(config refers to values not yet known)
<= data "aws_iam_policy_document" "fetch_status" {
- id = (known after apply)
- json = (known after apply)
- minified_json = (known after apply)
- statement {
- actions = [
- "ssm:GetParameter",
]
- resources = [
- "arn:aws:ssm:ap-northeast-1:123456789012:parameter/notify-demo/api-key",
]
}
- statement {
- actions = [
- "sns:Publish",
]
- resources = [
- (known after apply),
]
}
}
module.notify.data.aws_iam_policy_document.scheduler will be read during apply
(config refers to values not yet known)
<= data "aws_iam_policy_document" "scheduler" {
- id = (known after apply)
- json = (known after apply)
- minified_json = (known after apply)
- statement {
- actions = [
- "lambda:InvokeFunction",
]
- resources = [
- (known after apply),
]
}
}
module.notify.aws_cloudwatch_log_group.fetch_status will be created
- resource "aws_cloudwatch_log_group" "fetch_status" {
- arn = (known after apply)
- deletion_protection_enabled = (known after apply)
- id = (known after apply)
- log_group_class = (known after apply)
- name = "/aws/lambda/notify-demo-fetch-status"
- name_prefix = (known after apply)
- region = "ap-northeast-1"
- retention_in_days = 7
- skip_destroy = false
- tags_all = (known after apply)
}
module.notify.aws_cloudwatch_log_group.notify_slack will be created
- resource "aws_cloudwatch_log_group" "notify_slack" {
- arn = (known after apply)
- deletion_protection_enabled = (known after apply)
- id = (known after apply)
- log_group_class = (known after apply)
- name = "/aws/lambda/notify-demo-notify-slack"
- name_prefix = (known after apply)
- region = "ap-northeast-1"
- retention_in_days = 7
- skip_destroy = false
- tags_all = (known after apply)
}
module.notify.aws_iam_role.fetch_status will be created
- resource "aws_iam_role" "fetch_status" {
- arn = (known after apply)
- assume_role_policy = jsonencode(
{
- Statement = [
- {
- Action = "sts:AssumeRole"
- Effect = "Allow"
- Principal = {
- Service = "lambda.amazonaws.com"
}
},
]
- Version = "2012-10-17"
}
)
- create_date = (known after apply)
- force_detach_policies = false
- id = (known after apply)
- managed_policy_arns = (known after apply)
- max_session_duration = 3600
- name = "notify-demo-fetch-status-role"
- name_prefix = (known after apply)
- path = "/"
- tags_all = (known after apply)
- unique_id = (known after apply)
- inline_policy (known after apply)
}
module.notify.aws_iam_role.notify_slack will be created
- resource "aws_iam_role" "notify_slack" {
- arn = (known after apply)
- assume_role_policy = jsonencode(
{
- Statement = [
- {
- Action = "sts:AssumeRole"
- Effect = "Allow"
- Principal = {
- Service = "lambda.amazonaws.com"
}
},
]
- Version = "2012-10-17"
}
)
- create_date = (known after apply)
- force_detach_policies = false
- id = (known after apply)
- managed_policy_arns = (known after apply)
- max_session_duration = 3600
- name = "notify-demo-notify-slack-role"
- name_prefix = (known after apply)
- path = "/"
- tags_all = (known after apply)
- unique_id = (known after apply)
- inline_policy (known after apply)
}
module.notify.aws_iam_role.scheduler will be created
- resource "aws_iam_role" "scheduler" {
- arn = (known after apply)
- assume_role_policy = jsonencode(
{
- Statement = [
- {
- Action = "sts:AssumeRole"
- Condition = {
- StringEquals = {
- "aws:SourceAccount" = "123456789012"
}
}
- Effect = "Allow"
- Principal = {
- Service = "scheduler.amazonaws.com"
}
},
]
- Version = "2012-10-17"
}
)
- create_date = (known after apply)
- force_detach_policies = false
- id = (known after apply)
- managed_policy_arns = (known after apply)
- max_session_duration = 3600
- name = "notify-demo-scheduler-role"
- name_prefix = (known after apply)
- path = "/"
- tags_all = (known after apply)
- unique_id = (known after apply)
- inline_policy (known after apply)
}
module.notify.aws_iam_role_policy.fetch_status will be created
- resource "aws_iam_role_policy" "fetch_status" {
- id = (known after apply)
- name = (known after apply)
- name_prefix = (known after apply)
- policy = (known after apply)
- role = (known after apply)
}
module.notify.aws_iam_role_policy.notify_slack will be created
- resource "aws_iam_role_policy" "notify_slack" {
- id = (known after apply)
- name = (known after apply)
- name_prefix = (known after apply)
- policy = jsonencode(
{
- Statement = [
- {
- Action = "ssm:GetParameter"
- Effect = "Allow"
- Resource = "arn:aws:ssm:ap-northeast-1:123456789012:parameter/notify-demo/slack-webhook-url"
},
]
- Version = "2012-10-17"
}
)
- role = (known after apply)
}
module.notify.aws_iam_role_policy.scheduler will be created
- resource "aws_iam_role_policy" "scheduler" {
- id = (known after apply)
- name = (known after apply)
- name_prefix = (known after apply)
- policy = (known after apply)
- role = (known after apply)
}
module.notify.aws_iam_role_policy_attachment.fetch_status_logs will be created
- resource "aws_iam_role_policy_attachment" "fetch_status_logs" {
- id = (known after apply)
- policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
- role = "notify-demo-fetch-status-role"
}
module.notify.aws_iam_role_policy_attachment.notify_slack_logs will be created
- resource "aws_iam_role_policy_attachment" "notify_slack_logs" {
- id = (known after apply)
- policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
- role = "notify-demo-notify-slack-role"
}
module.notify.aws_lambda_function.fetch_status will be created
- resource "aws_lambda_function" "fetch_status" {
- architectures = (known after apply)
- arn = (known after apply)
- code_sha256 = (known after apply)
- filename = "./build/fetch_status.zip"
- function_name = "notify-demo-fetch-status"
- handler = "app.handler"
- id = (known after apply)
- invoke_arn = (known after apply)
- last_modified = (known after apply)
- memory_size = 128
- package_type = "Zip"
- publish = false
- qualified_arn = (known after apply)
- qualified_invoke_arn = (known after apply)
- region = "ap-northeast-1"
- reserved_concurrent_executions = -1
- response_streaming_invoke_arn = (known after apply)
- role = (known after apply)
- runtime = "python3.14"
- signing_job_arn = (known after apply)
- signing_profile_version_arn = (known after apply)
- skip_destroy = false
- source_code_hash = "So5oKi3b/bEDekVIzzKxoKwo1aO2n/JdG+Ioj2nhtVw="
- source_code_size = (known after apply)
- tags_all = (known after apply)
5 reactions
Parity Suite 3.6 and a month of emulator fixes
When I wrote up [3.0.0](/articles/parity-suite-300-release-notes) in August, Floci was a C on the board with 209 failing tests. By 3rd September the suite had grown to 1,251 tests and it was a D with 337. This morning's run has it failing one.
That's the real news in this release, and none of it is mine. The suite barely grew. The emulators got better.
The board
The suite reached 1,251 tests on 3rd September, so that's the fairest place to measure from. It's at 1,266 now.
| | 3rd September | 9th October |
|---|---|---|
| Floci | D, 337 failing | A, 1 failing |
| LocalStack | B, 101 failing | A, 31 failing |
| MiniStack | C, 220 failing | B, 84 failing |
All three now pass every Tier 1 test, the core of DynamoDB that most applications rely on. Five weeks ago Floci failed 50 of those, MiniStack 28 and LocalStack 14.
Floci didn't get there by doing less, either. Its coverage, the share of the suite it attempts rather than skips, went up from 95.1% to 99.4% over the same stretch.
Its one remaining failure is an edge case. A transaction carries a value nested deeper than DynamoDB's 32-level limit, but only in a condition, so it never gets written. Most regions don't check its depth and go on to evaluate the condition. eu-west-2 and the two other regions that now validate transactions up front (more on that below) refuse the whole request. Floci does neither: it cancels the transaction.
Most of LocalStack's 31 are PartiQL (21 of them), with the rest in exact error wording and limits. MiniStack's 84 are nearly all Tier 3, mostly exact error messages and limits.
It's good to see the board being used for what I built it for. Floci's tests and pull requests cite it by name, and MiniStack's maintainers point contributors to it when the question of how close to real DynamoDB they are comes up. Thanks to both teams for that.
AWS moved in the middle of a run
3.6.0 was tagged on 8th October and never published. While it was being measured, eu-west-2 changed how it validates transactions, so five tests failed against real DynamoDB and no board came out.
It's part of the same rollout I [noticed back in June](/articles/dynamodb-changed-its-validation-errors), and it still hasn't finished. eu-west-2, eu-north-1 and ap-northeast-2 now check a transaction's request before running it. The other 30 regions don't yet, and a few of the changes are more than wording: the same request can succeed in one region and fail in another.
For example, take an item that's already there, and a single SET that brings it up to exactly DynamoDB's 400KB limit (409,600 bytes), sent as an Update inside TransactWriteItems:
// bigValue brings the item to 409,600 bytes
await ddb.send(new TransactWriteItemsCommand({
TransactItems: [{
Update: {
TableName: 'orders',
Key: { pk: { S: 'Y' } },
UpdateExpression: 'SET p = :p',
ExpressionAttributeValues: { ':p': bigValue },
},
}],
}))
In us-east-1 that succeeds and the item is stored at 409,600 bytes. In eu-west-2, since 8th October, it fails with ValidationException: Item size to update has exceeded the maximum allowed size. If your tests run against one region and production sits in another, the two can disagree on this request. It's another reason [you can't pin DynamoDB to one region](/articles/you-cant-pin-dynamodb-to-one-region).
3.6.1 is the fix. Those five tests accept either answer until the regions settle, and no engine loses a test over it. Pinning to the old answer would fail real DynamoDB in eu-west-2, which is the region the suite measures against, and pinning to the new one would fail every engine that matches the 30 regions still giving the old answer. Both answers are what DynamoDB actually does today, so both count.
Smaller changes
The board now sorts by grade, best first. It used to sort on divergence alone, which could put a target with a worse grade above a better one. No grade or figure changed.
The suite grew by fifteen tests across 3.5.0 and 3.6, fourteen of them from exoego, covering size and nesting limits on batch and transaction writes.
Coming next
The suite's going to grow again soon. I've found some gaps in what it covers, and there are around 160 new tests waiting to go in, all checked against real DynamoDB first. I'll probably merge them early next week, and I'd expect most engines' figures to move when they do.
The full notes are in the 3.6.1 release, the suite is at paritysuite/dynamodb-conformance, and the board is at paritysuite.org. If something looks wrong, an issue is welcome.
1 comment
Ministack corre 60+ servicios de AWS en local desde un solo puerto. Alternativa gratuita a LocalStack con soporte real para RDS y Bedrock. ministackorg/ministack
1 like · 102 views
Runs 60+ AWS services locally on a single port as a free alternative to LocalStack with real infrastructure support for RDS and Bedrock.
ministackorg/ministack
#CyberSecurity #InfoSec #e0xsecops #TechLeadershi t.co
5 views
Mentions
Using StackPort with MiniStack MiniStack is an open-source AWS emulator that serves the whole API surface on a single port, 4566. StackPort gives it a web console: a dashboard of every service with live resource counts, and dedicated views for S3, DynamoDB, Lambda, SQS and more. StackPort talks to MiniStack over the standard AWS API, the same way the AWS CLI does. Nothing needs to be installed or enabled inside MiniStack.
PS5 Nahi Hai, Phir Bhi Game Chalta Hai: 6,270 Stars | 5 Emulator GitHub Repos #Shorts Top 5 trending GitHub repos, Hindi mein - har number aaj check kiya gaya hai. Is week ke top trending repos, 18k+ stars combined: 1. sharpemu/sharpemu (6k stars) - An experimental PlayStation 5 emulator for Windows, Linux and macOS. 2. ministackorg/ministack (5k stars) - Ministack: Free, open-source local AWS emulator - 60+ services, Terraform compatible, real databases. Free forever. MIT licensed. 3. davidmonterocrespo24/velxio (3k stars) - Emulate Arduino, ESP32 & Raspberry Pi. in your browser. Write code, compile, and run on 19 real boards — Arduino Uno, ESP32, ESP32-C3, Raspberry Pi Pico, Raspberry Pi 3, and more. No hardware, no cloud, no limits.. Discord: discord.gg 4. magnum6actual/flipoff (3k stars) - Free split-flap display emulator for any TV. The classic flip-board look, without the $3,500 hardware. 5. snuri00/ps2-web-recomp (838 stars) - PS2 games in the browser: static recompilation to WebAssembly with a native runtime Repo links: sharpemu/sharpemu ministackorg/ministack davidmonterocrespo24/velxio magnum6actual/flipoff snuri00/ps2-web-recomp Comment karo "RUN" - saare links DM mein bhej dunga. Kisi dev dost ko bhejo jise ye chahiye. Save karlo, baad mein kaam aayega. Naman AI - AI aur dev tools, Hindi mein. Har din naya repo, har number verified. #Shorts #GitHub #AI #OpenSource #TechHindi [github trending repos, best github repositories 2026, top 5 github repos, open source projects for beginners, ai coding agents, github stars] [coding in hindi, tech shorts hindi, programming for beginners, developer tools 2026, ai tools for developers, learn coding free] [bn] PS5 নেই, তাও গেম চলে: ৬,২৭০ স্টারস | ৫টি এমুলেটর গিটহাব রিপো #Shorts হিন্দিতে শীর্ষ ৫টি ট্রেন্ডিং গিটহাব রিপো - আজ প্রতিটি নম্বর চেক করা হয়েছে। এই সপ্তাহের শীর্ষ ট্রেন্ডিং রিপো, সম্মিলিতভাবে ১৮ হাজারেরও বেশি স্টার: ১. sharpemu/sharpemu (৬ হাজার স্টার) - উইন্ডোজ, লিনাক্স এবং ম্যাকওএস-এর জন্য একটি পরীক্ষামূলক প্লেস্টেশন ৫ এমুলেটর। ২. ministackorg/ministack (৫ হাজার স্টার) - মিনিস্ট্যাক: ফ্রি, ওপেন-সোর্স লোকাল AWS এমুলেটর - ৬০টিরও বেশি সার্ভিস, টেরাফর্ম কম্প্যাটিবল, আসল ডেটাবেস। আজীবন বিনামূল্যে। MIT লাইসেন্সপ্রাপ্ত। ৩. davidmonterocrespo24/velxio (৩ হাজার স্টার) - আপনার ব্রাউজারে আরডুইনো, ESP32 এবং রাস্পবেরি পাই এমুলেট করুন। ১৯টি আসল বোর্ডে কোড লিখুন, কম্পাইল করুন এবং চালান — আরডুইনো উনো, ESP32, ESP32-C3, রাস্পবেরি পাই পিকো, রাস্পবেরি পাই ৩, এবং আরও অনেক কিছু। কোনো হার্ডওয়্যার নেই, কোনো ক্লাউড নেই, কোনো সীমাবদ্ধতা নেই.. ডিসকর্ড: discord.gg ৪. magnum6actual/flipoff (৩ হাজার স্টার) - যেকোনো টিভির জন্য ফ্রি স্প্লিট-ফ্ল্যাপ ডিসপ্লে এমুলেটর। ৩,৫০০ ডলারের হার্ডওয়্যার ছাড়াই ক্লাসিক ফ্লিপ-বোর্ড লুক। ৫. snuri00/ps2-web-recomp (৮৩৮ স্টার) - ব্রাউজারে PS2 গেম: একটি নেটিভ রানটাইম সহ WebAssembly-তে স্ট্যাটিক রিকম্পাইলেশন। রেপো লিঙ্ক: sharpemu/sharpemu ministackorg/ministack davidmonterocrespo24/velxio magnum6actual/flipoff snuri00/ps2-web-recomp "RUN" কমেন্ট করুন - সব লিঙ্ক DM-এ পাঠিয়ে দেব। কিসি দেব দোস্ত কো ভেজো জিসে ইয়ে চাহিয়ে। করলো বাঁচাও, বাদ মে কাম আয়েগা। নমন এআই - এআই অর দেব টুল, হিন্দি মে। হার দিন নয়া রেপো, হার নম্বর যাচাই করা হয়েছে। #শর্টস #GitHub #AI #OpenSource #TechHindi [গিথুব ট্রেন্ডিং রিপোজ, সেরা গিথুব রিপোজিটরি 2026, শীর্ষ 5 গিথুব রিপোজ, নতুনদের জন্য ওপেন সোর্স প্রকল্প, এআই কোডিং এজেন্ট, গিথুব স্টার] [হিন্দিতে কোডিং, টেক শর্টস হিন্দি, নতুনদের জন্য প্রোগ্রামিং, ডেভেলপার টুল 2026, ডেভেলপারদের জন্য এআই টুলস, ফ্রি কোডিং শিখুন]
ministack: Free local AWS emulator for your dev machine Run 60+ AWS services on one port with no sign-up. It's drop-in compatible with boto3, the AWS CLI and Terraform, and uses real databases. Perfect for fast local testing and CI pipelines. Built by the Ministack.org team: ministackorg/ministack
Free AWS emulator you can run locally Run 60+ AWS services on one port with no sign-up. It's drop-in compatible with boto3, the AWS CLI and Terraform, and uses real databases. 🍒 Built by the Ministack.org team ministackorg/ministack #opensource #python
What Replaced queue:work in My Laravel App (And Why I Stopped Praying to Supervisor at 3 AM) > Versión en español aquí. --- !Banner Image --- In the previous chapter we figured out where our data lives: files in S3, cache and sessions in DynamoDB, and a NoSQL database that speaks Eloquent. But one question was left hanging in the air: if a Lambda function is born, responds, and dies in milliseconds, who stays awake processing jobs in the background? On a traditional server, the answer is supervisord babysitting a long-running process. In an ephemeral environment where nothing lives longer than 15 minutes, that recipe simply doesn't work. In this chapter we're going to replace that daemon with SQS + a Lambda worker. We'll do it in three acts: first the setup _(surprisingly short)_, then the lifecycle of a job _(where the magic happens)_, and finally the topic almost nobody explains in full: what happens when a job fails, who retries it, and where it ends up. --- 1. The Serverless Treasure Map Quick recap, so we know where we are: !AWS Serverless architecture We already have Lambda running our Laravel app, CloudFront splitting traffic between S3 _(assets)_ and Lambda _(the app)_, and DynamoDB serving as cache, sessions, and our primary database. Today's agenda: - Managing our event queue with SQS, - and a second Lambda function for the Laravel Queue _worker_. Remember the component table from the second chapter? Today we cross off this row: | Component | Dedicated Server | AWS Serverless | |-------------|-------------------------------------------------------|-----------------| | Event Queue | SSH $ php artisan queue:work / maybe supervisor | SQS + Lambda 🚀 | --- 2. Why queue:work Doesn't Fit in Serverless a) How queues work on a traditional server Oversimplifying a bit, php artisan queue:work is a while (true) loop: it asks the queue if there's any work; if there is, it processes it; if not, it sleeps for a few seconds and asks again. A resident process hogging memory and CPU 24/7, even if the queue has been empty for 3 days. And because it's a long-running process, it needs a babysitter: - supervisor _(or systemd)_ to resurrect it when it dies. - queue:restart after every deploy, because the process still has the old code loaded in memory. - --max-jobs and --max-time to recycle it before memory leaks turn it into a black hole. - And to scale? More processes → more RAM → bigger instances → all accompanied by a bigger bill. > queue:work is an employee getting paid to hit F5 on their inbox every three seconds. Eight hours a day. Even when nothing comes in. b) Why that doesn't exist in Lambda A Lambda invocation lives, at most, for 15 minutes [^1]. There is no resident process, no while (true), and nobody asking _"got any work?"_. If you try running queue:work inside a Lambda... it works for 15 minutes, dies, and nobody brings it back. And if you resurrect it with a cron job, you're paying for a Lambda 24/7 just to poll. Congratulations, you just reinvented EC2, only more expensive. c) Flipping the model: from _pull_ to _push_ The solution isn't to _adapt_ the worker to Lambda—it's to flip it upside down. Instead of the worker pulling work _(pull)_, the queue invokes the worker when work arrives _(push)_: 1. Laravel calls dispatch() and pushes the job to SQS. 2. SQS detects new messages and invokes a Lambda function with those messages. 3. Lambda processes the jobs, finishes, and disappears. 4. An empty queue = zero Lambda invocations. > _The worker stops being a process that waits and becomes an event that happens._ --- 3. The New Queue Stack: SQS as the Mailbox, Lambda as the Worker a) SQS in 30 seconds for Laravel devs Amazon SQS _(Simple Queue Service)_ is a managed message queue [^2]. The bare minimum you need to know: - Message: for us, the serialized JSON payload of the job. The exact same one you'd see in the payload column of the jobs table if you were using the database driver. - Visibility timeout: when someone picks up a message, SQS _hides_ it for X seconds. If nobody deletes it within that window, it reappears so another consumer can try again. It's the equivalent of Laravel's retry_after. - Dead Letter Queue _(DLQ)_: a second queue where SQS moves messages that have failed too many times. The _"lost and found box"_. - Standard vs FIFO: standard queues don't guarantee order _(and they're the ones we'll use)_; FIFO queues do, in exchange for lower throughput. > _With the vocabulary out of the way, let's jump into the code._ b) Wiring it up with Serverless Lift Starting from the serverless.yml we built in the second chapter, we add two environment variables and a Lift construct [^3] [^4]: service: laravel provider: name: aws environment: # ... + QUEUE_CONNECTION: sqs + SQS_QUEUE: ${construct:jobs.queueUrl} functions: web: handler: Bref\LaravelBridge\Http\OctaneHandler runtime: php-85 # ... constructs: website: type: server-side-website # ... + jobs: + type: queue + worker: + handler: Bref\LaravelBridge\Queue\QueueHandler + runtime: php-85 + timeout: 60 # seconds, the ceiling for the longest job plugins: - ./vendor/bref/bref - serverless-lift QUEUE_CONNECTION: sqs enables the driver Laravel ships with out of the box, and SQS_QUEUE receives the full URL of the queue Lift just created _(if you prefer passing only the queue name, you'll also need SQS_PREFIX [^3])_. > _Even though on a traditional server the queue runs via CLI with artisan, here we don't use php-85-console. Just like with OctaneHandler, we use the php-85 event runtime with Bref's QueueHandler class._ And what did Lift do behind the scenes with that jobs construct? [^4] - Created the SQS queue. - Created a Dead Letter Queue with a 14-day retention period _(the maximum SQS allows)_. - Created the worker Lambda function with Bref's handler. - Configured the event source mapping: the AWS piece that connects the SQS queue to the Lambda function and invokes it when messages arrive. Our replacement for queue:work. - And granted sqs:SendMessage permission to all functions in the stack [^5]. That's why, unlike the DynamoDB tables from the previous chapter, we didn't have to write a single line of IAM here. > _Credentials, just like with DynamoDB, are assigned to each function's execution role. They rotate automatically and nobody ever sees them._ c) New infrastructure, same old code This is my favorite part. Nothing you've already written has any idea anything changed: // A job SendWelcomeEmail::dispatch($user->uuid); // A queued mailable Mail::to($user)->queue(new WelcomeMail($user)); // A notification implementing ShouldQueue $user->notify(new VerifyEmail); All of that goes to SQS, and a Lambda processes it. ShouldQueue listeners, chained jobs... almost everything works the exact same way. Another point for Laravel's abstraction 💪. > Important: Laravel's SQS driver caps delay() / #[Backoff] at 900 seconds (15 minutes), which is the SQS limit [^6]. > If you need multiple queues _(one for urgent emails, another with lower concurrency to avoid hammering an external API, a FIFO queue for strict ordering)_, each one is an additional queue construct, and since Laravel 13.26 they can be routed with Queue::forward() [^7]. --- 4. What Actually Happens When You Dispatch a Job? Up to this point it all looks like magic: we added a block in serverless.yml, called dispatch(), and the job ran. But in production, magic only lasts until the first incident. Let's walk step by step through _who_ does _what_, from the moment a message enters SQS until it disappears. a) Laravel dispatches, SQS receives When you call SendWelcomeEmail::dispatch($user->uuid), Laravel serializes the job and the sqs driver makes a SendMessage call to the SQS API to drop it into the queue. And that's it—our web Lambda has done its part and can finish its execution. > _The message body is the same old JSON (displayName, job, data, uuid, attempts). To Laravel, SQS is just another mailbox to drop that exact same envelope into._ b) SQS notifies, Lambda wakes up The _event source mapping_ that Lift created handles _long polling_ for us [^8]: AWS keeps an internal process polling the queue and, as soon as it detects messages, invokes our worker Lambda function with a batch of up to batchSize messages _(Lift defaults this to 1; we'll see why and when to change it shortly)_. Polling still exists; the difference is that instead of your queue:work process doing it, AWS takes care of it now. > Important: That AWS polling counts as ReceiveMessage calls in SQS, even when the queue is empty. Fortunately, the free tier includes 1 million monthly requests [^9]: with an idle queue, that constant check generates ~21,000 requests per day _(~630,000 per month)_, so you pay nothing. c) Bref receives the batch, Laravel runs the job When Lambda wakes up, it hands the SQS event to Bref's QueueHandler [^10]. If we peek inside its handleSqs() method _(simplified)_, we can see exactly what happens with each message: public function handleSqs(SqsEvent $event, Context $context): void { $worker = $this->container->makeWith(Worker::class, [ 'resetScope' => fn () => $this->resetLaravel(), // ... ]); foreach ($event->getRecords() as $sqsRecord) { $timeout = $this->calculateJobTimeout($context->getRemainingTimeInMillis()); $worker->runSqsJob( $job = $this->marshalJob($sqsRecord), $this->connection, $this->gatherWorkerOptions($timeout), ); if (! $job->hasFailed() && ! $job->isDeleted()) { $job->delete(); } } } Three key details in those few lines: 1. That Worker is Laravel's. It literally extends Illuminate\Queue\Worker [^11]. Bref didn't reinvent how to run a job: it passes each message to the exact same engine that queue:work uses. Direct consequence: #[Tries], #[Backoff], $timeout, failed(), middleware, ShouldBeUnique, retryUntil(), and the JobProcessing/JobProcessed/JobFailed events... everything works just like on a traditional server. There's no _"supported features"_ table to check. 2. State is cleaned up between jobs. That resetLaravel() callback flushes log context, forgets _scoped_ container instances, and clears resolved facades before each run. Why? For the exact same reason we saw with Octane in the second chapter: the Lambda instance _(and its PHP process)_ survives from one job to the next. Without that cleanup, the second job would inherit the in-memory leftovers of the first. 3. Deleting from SQS is explicit. Only if the job finished without failing does Bref call $job->delete() to remove it from the queue. If it failed or was already deleted _(or released back to the queue, as we'll see next)_, it leaves it alone. d) Lambda's timeout vs. the job's $timeout Did you spot that calculateJobTimeout($context->getRemainingTimeInMillis()) inside the loop? Bref doesn't use a fixed timeout: it checks how many milliseconds the Lambda invocation has left to live _(minus a 1-second safety margin)_ and assigns that time to the job. With that value, the Worker schedules a pcntl_alarm() [^11]: if the job runs over, the alarm goes off, the process exits with exit(1), and the invocation fails. The job is only marked as failed _(and sent to failed_jobs)_ if you defined #[FailOnTimeout] or $failOnTimeout = true. Two practical takeaways: - The worker's timeout in serverless.yml is, in practice, the ceiling for the longest job you can process. Set it with your heaviest task in mind. - With a batchSize greater than 1, all jobs in the batch share that same clock: if you process long-running tasks, keep the batch small _(or leave it at 1)_. > Watch out for #[Timeout] / $timeout: If you define a custom timeout on the job class, Laravel overrides the value calculated by Bref. If that number exceeds the time remaining in Lambda, AWS will kill the invocation cold before PHP's alarm ever rings. Recommendation: use a value well below the worker's timeout, especially with batchSize greater than 1, where the last jobs in the batch start with less time available. --- 5. Who Takes Over When Something Blows Up? We all love watching the happy path play out by the book: the job comes in, runs, and gets deleted from SQS. But in the real world, external APIs go down, validations fail, and processes run out of memory. > You're probably wondering: _"If Lift already created a Dead Letter Queue in SQS and Laravel already has its own failed_jobs table, aren't they doing the exact same thing?"_ The answer is no. When we combine Laravel's Worker with SQS and Lambda, two retry and failure mechanisms coexist in the same stack: one on our application side and another on the AWS infrastructure side. And even though they sound similar, they tackle completely different problems. a) Retries are handled by Laravel, not SQS Let's start on our application side. Picture an ordinary job configured with retries and backoff: use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\Tries; #[Tries(3)] #[Backoff(30)] final class SendWelcomeEmail implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; public function __construct(public readonly string $user_uuid) {} public function handle(): void { // ... if this throws an exception, Laravel retries in 30 seconds, up to 3 times } public function failed(Throwable $e): void { // ... once all 3 attempts are exhausted, Laravel calls this } } When handle() throws an exception, Laravel's Worker does what it always does: if attempts remain, it calls $job->release(30). And here's the trick. Laravel's native SqsJob simply updates the message's _visibility timeout_ in SQS, but Bref overrides it and does something else [^12]: public function release($delay = 0) { $this->released = true; $payload = $this->payload(); $payload['attempts'] = ($payload['attempts'] ?? 0) + 1; // deletes the original message... $this->sqs->deleteMessage([ 'QueueUrl' => $this->queue, 'ReceiptHandle' => $this->job['ReceiptHandle'], ]); // ...and sends a brand-new copy, with the updated attempt counter and backoff delay $this->sqs->sendMessage([ 'QueueUrl' => $this->queue, 'MessageBody' => json_encode($payload), 'DelaySeconds' => $this->secondsUntil($delay), ]); } > It's not _"forwarding"_. It's deleting the email and composing a brand-new one, identical, with [Attempt 2] in the subject line and scheduled to send in 30 seconds. Why? Because that way the attempt counter travels inside the payload _(attempts() reads it from there, not from SQS's ApproximateReceiveCount)_, and backoff translates into a real DelaySeconds. As a result, #[Tries(3)] and #[Backoff(30)] behave exactly like they do on a server. Bref deliberately chose Laravel's semantics over SQS's [^3]. > If you don't define #[Tries] / $tries, Bref's handler defaults to 3 [^13]. And if you use exponential backoff _(#[Backoff([10, 60, 300])])_, that works too: each re-queued message carries the delay corresponding to that attempt. b) The side effect: SQS never finds out about the failure When an exception is thrown inside handle(), Laravel's Worker catches it in its own try/catch: it reports it to the logs, decides whether to retry or mark the job as failed, and moves on. Because that exception never bubbles up to handleSqs(), as far as AWS Lambda is concerned, the function succeeded without errors. This means SQS's native retry mechanism _(visibility timeout → maxReceiveCount → DLQ)_ never finds out that a job failed due to a PHP exception. Not on the first attempt, and not on the third. > _Laravel is that teammate who says "all good" in the daily standup while production is on fire. And SQS believes them._ c) First layer: when the job exhausts its attempts in Laravel When a job fails for the third time and runs out of attempts, Laravel gives up: it calls the class's failed() method and records the failure using the driver configured in config/queue.php _(queue.failed)_. The problem is that, by default, Laravel tries to save that record in a failed_jobs table inside a SQL database... which we said goodbye to in the previous chapter. Without a relational database, the quick way out is to disable it altogether with QUEUE_FAILED_DRIVER: null [^3]: the exception still ends up in CloudWatch logs, but the job itself isn't stored anywhere. I don't recommend it. The good news is that Laravel already includes native support for storing failed_jobs in DynamoDB [^14]. We only need to tweak config/queue.php: 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), // When using driver=dynamodb there's no need to specify 'database' // 'database' => env('DB_CONNECTION', 'sqlite'), 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'token' => env('AWS_SESSION_TOKEN'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_FAILED_JOBS_TABLE', 'failed_jobs'), ], provider: environment: QUEUE_FAILED_DRIVER: dynamodb DYNAMODB_FAILED_JOBS_TABLE: !Ref FailedJobsTable And we declare the table under resources in serverless.yml. Laravel uses application as the partition key _(the app name)_ and uuid as the sort key: FailedJobsTable: Type: AWS::DynamoDB::Table Properties: TableName: failed_jobs BillingMode: PAY_PER_REQUEST AttributeDefinitions: - { AttributeName: application, AttributeType: S } - { AttributeName: uuid, AttributeType: S } KeySchema: - { AttributeName: application, KeyType: HASH } - { AttributeName: uuid, KeyType: RANGE } TimeToLiveSpecification: AttributeName: expires_at Enabled: true > Just like we did in the previous chapter for the cache and session tables, we use TimeToLiveSpecification; Laravel saves each failed job with an expiration timestamp _(expires_at, 7 days by default [^14])_. This driver doesn't even implement queue:flush: DynamoDB takes care of cleaning up expired records automatically. Finally, we grant the execution role permissions on the table: provider: iam: role: statements: - Effect: Allow Resource: !GetAtt FailedJobsTable.Arn Action: - dynamodb:PutItem - dynamodb:Query - dynamodb:GetItem - dynamodb:DeleteItem To list or retry failed jobs—since there's no SSH in Lambda—we use the bref:cli bridge we saw in the second chapter: serverless bref:cli --args="queue:failed" serverless bref:cli --args="queue:retry all" > _"Wait, so what is the DLQ that Lift created even for?"_ d) Second layer: when the infrastructure fails A message only reaches the _Dead Letter Queue_ when the entire Lambda invocation fails, without giving Laravel a chance to catch the error. For example: - A job exceeded the remaining invocation time (pcntl_alarm went off and execution terminated with exit(1)). - It ran out of memory _(OOM)_. - A fatal PHP error outside the scope of the Worker. - A boot failure: a missing extension, a configuration error, a composer install that left something out. In any of these scenarios, Lambda dies before Bref can delete the message, so SQS's native mechanism kicks in: 1. Wait (_visibility timeout_): the message stays hidden until its visibility timeout expires. Lift automatically sets it to 6 times the worker's timeout following AWS's recommendation [^15] _(with the 60 seconds from our example, it reappears after 6 minutes)_. 2. Retry: once visible again, SQS hands it back to Lambda. 3. Off to the DLQ: if the invocation fails 3 times in a row _(maxRetries by default in Lift)_, SQS removes the message from the main queue and stores it in the Dead Letter Queue for up to 14 days, preventing it from being lost. To inspect what landed in the DLQ or re-queue it without opening the AWS console, Lift gives us these commands [^16]: serverless jobs:logs --tail # live worker logs serverless jobs:failed # inspect messages in the DLQ serverless jobs:failed:retry # move everything back to the main queue serverless jobs:failed:purge # delete everything and move on with life Of course, nobody wants to run serverless jobs:failed every day just in case. If you add the alarm property to the construct, Lift automatically sets up a CloudWatch alarm that sends you an email as soon as a message lands in the DLQ [^4]: constructs: jobs: type: queue # ... alarm: alerts@my-domain.com # email alert when the DLQ receives messages > After the first serverless deploy, AWS will send a confirmation email to the address configured in alarm _(don't forget to confirm it to activate the alerts)_. e) The two layers, side by side | | First layer: failed_jobs | Second layer: SQS DLQ | |------------------------|----------------------------------------|--------------------------------------------------| | What failed? | Our code: exception in handle() | Infrastructure: timeout, OOM, fatal error | | Who retries? | Laravel (#[Tries], #[Backoff]) | SQS (_visibility timeout_, maxRetries) | | What gets retried? | That job, after configured backoff | The in-flight job and rest of batch (6× timeout) | | Where does it end? | failed_jobs table (DynamoDB, 7d) | Dead Letter Queue (SQS, 14 days) | | How do I retry it? | bref:cli --args="queue:retry ..." | serverless jobs:failed:retry | | How do I find out? | failed(), CloudWatch, queue:failed | Email alert (alarm:) | Take a look at the What gets retried? row in the second layer. When Lambda dies mid-batch _(due to timeout or OOM)_, the jobs that had already finished were deleted one by one with $job->delete(), so they don't run again. SQS only re-delivers what remained undeleted: the job that was halfway through execution and the ones waiting behind it in the batch. The danger lies in that half-finished job: when SQS retries it, it will run again from the very beginning. If it had already charged a credit card or sent an email a second before Lambda died and isn't idempotent, it will do it a second time _(and you'll get acquainted with the word "refund" much sooner than you'd like)_. > Golden rule: always design idempotent jobs. --- 6. Tuning the Worker Knobs for Production The 8 lines from section 3 are enough to get started, but now that we know what happens under the hood, we can tune the knobs for production. Here's what a battle-tested configuration looks like: constructs: jobs: type: queue maxRetries: 5 # queue maxReceiveCount: AWS recommends >= 5 maxConcurrency: 20 # the "clean" brake: Lambda stops polling, messages wait worker: handler: Bref\LaravelBridge\Queue\QueueHandler runtime: php-85 layers: - ${bref-extra:gd-php-85} reservedConcurrency: 25 # hard cap & reservation; always >= maxConcurrency timeout: 300 batchSize: 10 a) timeout: the ceiling for the longest job It defines both the maximum duration of an invocation and the queue's _visibility timeout_ in SQS _(which Lift multiplies by 6)_. In my case I bumped it to 300 seconds because the worker processes heavy images, and resizing a 20 MB photo takes time. If your jobs only send emails or call an API, 30 or 60 seconds is more than enough. b) batchSize: fewer invocations, more responsibility Lift leaves it at 1 by default to keep error handling simple [^17]. Raising it to 10 means a burst of 500 jobs is processed in 50 invocations instead of 500, reducing _cold-starts_ and costs. In exchange, remember the two conditions we covered earlier: all jobs in the batch share the same Lambda clock, and our jobs must be idempotent in case an invocation dies mid-batch. > There's also maxBatchingWindow: how many seconds SQS waits to gather a full batch before invoking Lambda. Zero by default. If you don't mind a job waiting a few seconds for company to arrive, you can raise it. c) reservedConcurrency vs maxConcurrency If 1,000 jobs arrive at once, Lambda scales up fast to hundreds of simultaneous invocations. And that sounds wonderful until those 1,000 jobs slam into a rate-limited external API, our SMTP server, or DynamoDB itself all at the same time. There are two ways to put a ceiling on it: - reservedConcurrency _(function property)_: the function never runs more than N simultaneous instances. But it also reserves those N from your account's total concurrency quota, even when sitting idle [^18]. - maxConcurrency _(Lift construct property)_: limits how many concurrent invocations this queue's event source mapping can trigger, without reserving anything [^19]. The sweet spot is combining both: maxConcurrency as a clean brake _(messages wait in the queue without burning attempts)_ and reservedConcurrency slightly above it, as a safety net in case a bug dispatches jobs in an infinite loop. > Heads up: if you use only reservedConcurrency, anything above that limit gets _throttled_, and every rejection counts as a delivery attempt in SQS. With maxRetries: 3 _(Lift's default)_, a traffic spike can send jobs straight to the DLQ without ever having executed once. That's why AWS recommends setting maxRetries to at least 5 [^20]. d) memorySize: more memory is also more CPU As we saw in the second chapter, CPU power in Lambda scales proportionally with allocated memory. A heavy job that takes 10 seconds with 512 MB might finish in 3 seconds with 1,769 MB; and since AWS bills per GB-second, giving it more memory often ends up being cheaper. If your worker does CPU-intensive work, you can assign its own memorySize and find the sweet spot with aws-lambda-power-tuning [^21]. e) Tailored layers: extensions only where needed Did you notice the ${bref-extra:gd-php-85} layer in the configuration? My worker needs the GD extension to process images, but the web HTTP function doesn't. Because they are separate Lambda functions, each one loads only the extensions it actually uses: web requests stay lightweight, and the extra weight is carried exclusively by the background worker. f) Does the worker need a warmer? In the second chapter we set up a _warmer_ every 5 minutes to avoid _cold-starts_ on web requests. Does the worker need one? Not at all. A 200 ms _cold-start_ on an HTTP route is felt by a user staring at a loading screen; in a background queue, an extra 200 ms won't keep anyone up at night. --- 7. What Tutorials Don't Tell You (and You Find Out in Production) Things that aren't in the docs _(or were buried in the fine print and I learned the hard way)_: a) SerializesModels and DynamoDB composite keys Did you notice that in SendWelcomeEmail _(section 5.a)_ I passed $user_uuid as a string instead of the User model? That wasn't an accident. SerializesModels stores only getKey() _(PK)_ and tries to restore the model with a traditional SQL query that doesn't include the SK or support DynamoDB. For your own jobs, the simplest approach is passing the uuid to the constructor and fetching the model inside handle(). However, if you use queued notifications _($user->notify())_, Laravel serializes the User model whether you like it or not: in that case, you have no choice but to override getQueueableId() and newQueryForRestoration() on your model to encode and reconstruct PK + SK. b) Bus::batch() also needs somewhere to store its progress By default it looks for the relational job_batches table, but Laravel includes native DynamoDB support: you just need to create the job_batches table _(partition key application, sort key id)_, grant IAM permissions, and set queue.batching.driver to dynamodb [^22]. c) Your local environment doesn't need to touch AWS Since our job code doesn't change based on the driver, on your machine you can keep using QUEUE_CONNECTION=database _(or sync)_ with good old queue:work, or emulate SQS locally with tools like MiniStack [^23] or LocalStack [^24]. QUEUE_CONNECTION=sqs pointing to AWS is only needed in serverless.yml. d) The limit is 1 MiB per message That's SQS's hard limit [^25]. Jobs should carry lightweight identifiers, not base64-encoded files or collections of 10,000 items. If you're anywhere near that limit, your design is screaming at you to have the job fetch the data when it runs instead of receiving it in the payload. e) The AWS SDK cleanup from the second chapter If you used aws/aws-sdk-php with removeUnusedServices in composer.json to shrink your deploy size, make sure to add Sqs to the list of allowed services: "extra": { "aws/aws-sdk-php": [ "DynamoDb", "S3", "Sqs" ] } Why is it so easy to forget? Because tests run with QUEUE_CONNECTION=sync or Queue::fake(), so CI passes everything in green without ever instantiating SqsClient... until the first real dispatch() in production blows up because the class was stripped out. --- 8. Costs: The Worker That Only Charges When It Works Let's update the tab, just like in every chapter. - Before (EC2): the worker lived on one of the t3.small instances from the first chapter _(~$15/month per instance)_. A resident queue:work process with reserved RAM and supervisord running 24/7, even when the queue spent 90% of the day empty. - Now (SQS + Lambda): - SQS: the first million monthly requests are free; after that it costs \$0.40 per million [^26]. Adding up the ~630,000 requests from idle polling and 3 requests per job _(SendMessage, ReceiveMessage, and DeleteMessage)_, you can process up to ~120,000 jobs per month for $0.00. - Lambda: the perpetual free tier includes 1 million invocations and 400,000 GB-seconds per month. With no jobs in the queue, compute costs literally zero. Let's put numbers on a month with 100,000 jobs _(averaging ~500 ms with 512 MB of RAM)_: | Item | Within Free Tier | Full Price _(no Free Tier)_ | |----------------------------|---------------------:|--------------------------------:| | SQS Polling (~630k req/mo) | $0.00 | $0.25 | | SQS Messages (300k req/mo) | $0.00 | $0.12 | | Lambda Worker (100k jobs) | $0.00 | $0.44 | | Monthly total | $0.00 | ~$0.81 | > _Even without the free tier, processing 100,000 jobs costs less than a dollar a month!_ And most importantly: with DynamoDB on PAY_PER_REQUEST, Lambda billing per millisecond, and SQS billing per request, almost no component in the queue system charges a flat fee just for existing _(except for SQS polling, which in my case falls within the free tier)_. --- 9. What's Next? Today we retired supervisord and the 24/7 queue:work process for good: we replaced them with an SQS queue and a Lambda worker that only wakes up when there's work to do. We saw how Bref reuses Laravel's own Worker _(so our jobs don't change)_, how its release() trick preserves retries and backoff, and how failed_jobs in DynamoDB coexists with SQS's _Dead Letter Queue_. But there's still a loose end between the previous chapter and this one. In section 6 I mentioned that my worker loads the GD extension to resize images; now, if in the previous chapter we saw that files travel straight from the browser to S3 without touching our application... who tells Laravel that a file finished uploading so it can queue that job? In the next chapter we'll close that loop with S3 Event Notifications, and we'll see why the solution is _not_ wiring S3 directly to the queue we just built. --- A question for you: what are you running your Laravel queues on in production today, or what was the worst stunt a queue worker ever pulled on you at 3 AM? Let me know in the comments: the best production war stories come from there, not from post-mortems. [^1]: docs.aws.amazon.com [^2]: docs.aws.amazon.com [^3]: bref.sh [^4]: github.com/getlift/lift/blob/master/docs/queue.md [^5]: github.com/getlift/lift/blob/master/docs/permissi… [^6]: docs.aws.amazon.com [^7]: bref.sh [^8]: docs.aws.amazon.com [^9]: aws.amazon.com [^10]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^11]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^12]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^13]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^14]: laravel.com [^15]: github.com/getlift/lift/blob/master/docs/queue.md [^16]: github.com/getlift/lift/blob/master/docs/queue.md [^17]: github.com/getlift/lift/blob/master/docs/queue.md [^18]: docs.aws.amazon.com [^19]: github.com/getlift/lift/blob/master/docs/queue.md [^20]: docs.aws.amazon.com [^21]: alexcasalboni/aws-lambda-power-tuning [^22]: laravel.com [^23]: ministackorg/ministack [^24]: localstack/localstack [^25]: docs.aws.amazon.com [^26]: aws.amazon.com
Qué Reemplazó a queue:work en Mi Aplicación Laravel (Y Por Qué Dejé de Rezarle a Supervisor a las 3 AM) > English version here. --- !Banner Image --- En el capítulo anterior resolvimos dónde viven los datos: archivos en S3, caché y sesiones en DynamoDB, y una base de datos NoSQL que habla Eloquent. Pero quedó una pregunta flotando en el aire: si una función Lambda nace, responde y muere en milisegundos, ¿quién se queda despierto procesando los jobs en segundo plano? En un servidor tradicional la respuesta es supervisord vigilando un proceso eterno. En un entorno efímero donde nada vive más de 15 minutos, esa receta simplemente no aplica. En este capítulo vamos a reemplazar ese daemon por SQS + un worker en Lambda. Lo haremos en tres actos: primero la configuración _(sorprendentemente corta)_, después el ciclo de vida de un job _(donde está la magia)_, y finalmente el tema que casi nadie explica completo: qué pasa cuando un job falla, quién lo reintenta, y dónde termina. --- 1. El Mapa del Tesoro Serverless Rápido, para ubicarnos: !AWS Serverless architecture Ya tenemos Lambda ejecutando nuestra App Laravel, CloudFront repartiendo tráfico entre S3 _(assets)_ y Lambda _(la app)_, y DynamoDB como caché, sesiones y nuestra base de datos principal. Hoy toca: - Gestionar nuestra cola de eventos con SQS, - y una segunda función Lambda para el _worker_ de Laravel Queue. ¿Recuerdan la tabla de componentes del segundo capítulo? Hoy tachamos esta fila: | Componente | Servidor Dedicado | AWS Serverless | |-----------------|--------------------------------------------------------|-----------------| | Cola de Eventos | SSH $ php artisan queue:work / quizás supervisor | SQS + Lambda 🚀 | --- 2. Por Qué queue:work No Encaja en Serverless a) Cómo funciona la cola en un servidor tradicional Simplificando mucho, php artisan queue:work es un while (true): le pregunta a la cola si hay trabajo; si hay, lo procesa; si no, duerme unos segundos y vuelve a preguntar. Un proceso residente que ocupa memoria y CPU las 24 horas, aunque la cola esté vacía desde hace 3 días. Y como es un proceso, necesita niñera: - supervisor _(o systemd)_ para resucitarlo cuando muere. - queue:restart después de cada deploy, porque el proceso tiene el código viejo cargado en memoria. - --max-jobs y --max-time para reciclarlo antes de que los memory leaks lo conviertan en un agujero negro. - ¿Y para escalar? Más procesos → más RAM → instancias más grandes → todo acompañado de una factura mayor. > queue:work es un empleado al que le pagan por presionar F5 en la bandeja de entrada cada tres segundos. Ocho horas. Aunque no llegue nada. b) Por qué eso no existe en Lambda Una invocación de Lambda vive, como máximo, 15 minutos [^1]. No hay proceso residente, no hay while (true), no hay nadie preguntando _"¿hay trabajo?"_. Si intentan correr queue:work dentro de una Lambda... funciona 15 minutos, muere, y nadie lo resucita. Y si lo resucitan con un cron, están pagando una Lambda las 24 horas para hacer polling. Felicidades, reinventaron EC2, pero más caro. c) La inversión del modelo: de _pull_ a _push_ La solución no es _adaptar_ el worker a Lambda, es darlo vuelta. En vez de que el worker busque trabajo _(pull)_, la cola invoca al worker cuando hay trabajo _(push)_: 1. Laravel hace dispatch() y registra el job en SQS. 2. SQS detecta que hay mensajes e invoca a una función Lambda con esos mensajes. 3. Lambda procesa los jobs, termina y desaparece. 4. Una cola vacía = cero invocaciones Lambda. > _El worker deja de ser un proceso que espera y pasa a ser un evento que ocurre._ --- 3. El Nuevo Stack de Colas: SQS como Buzón, Lambda como Worker a) SQS en 30 segundos para devs Laravel Amazon SQS _(Simple Queue Service)_ es una cola de mensajes administrada [^2]. Lo mínimo que necesitan saber: - Mensaje: para nosotros, el payload JSON serializado del job. El mismo que verían en la columna payload de la tabla jobs si usaran el driver database. - Visibility timeout: cuando alguien toma un mensaje, SQS lo _oculta_ durante X segundos. Si en ese tiempo nadie lo borra, reaparece para que otro lo intente. Es el equivalente al retry_after de Laravel. - Dead Letter Queue _(DLQ)_: una segunda cola donde SQS mueve los mensajes que fallaron demasiadas veces. La _"caja de objetos perdidos"_. - Standard vs FIFO: las colas estándar no garantizan orden _(y son las que vamos a usar)_; las FIFO sí, a cambio de menos throughput. > _Con el vocabulario listo, pasemos al código._ b) Conectando los cables con Serverless Lift Partimos del serverless.yml que construimos en el segundo capítulo y agregamos dos variables de entorno y un construct de Lift [^3] [^4]: service: laravel provider: name: aws environment: # ... + QUEUE_CONNECTION: sqs + SQS_QUEUE: ${construct:jobs.queueUrl} functions: web: handler: Bref\LaravelBridge\Http\OctaneHandler runtime: php-85 # ... constructs: website: type: server-side-website # ... + jobs: + type: queue + worker: + handler: Bref\LaravelBridge\Queue\QueueHandler + runtime: php-85 + timeout: 60 # segundos, el techo del job más largo plugins: - ./vendor/bref/bref - serverless-lift QUEUE_CONNECTION: sqs activa el driver que Laravel ya trae de fábrica, y SQS_QUEUE recibe la URL completa de la cola que Lift acaba de crear _(si prefieren pasar solo el nombre, van a necesitar también SQS_PREFIX [^3])_. > _Aunque en un servidor tradicional la cola se corre por CLI con artisan, acá no usamos php-85-console. Al igual que con OctaneHandler, usamos el runtime de eventos php-85 con la clase QueueHandler de Bref._ ¿Y qué hizo Lift por detrás con ese construct jobs? [^4] - Creó la cola SQS. - Creó una Dead Letter Queue con retención de 14 días _(el máximo que permite SQS)_. - Creó la función Lambda worker con el handler de Bref. - Configuró el event source mapping: la pieza de AWS que conecta la cola SQS con la función Lambda, y la invoca cuando hay mensajes. Nuestro reemplazo de queue:work. - Y le dio permiso sqs:SendMessage a todas las funciones del stack [^5]. Por eso, a diferencia de las tablas DynamoDB del capítulo anterior, acá no escribimos ni una línea de IAM. > _Las credenciales, al igual que con DynamoDB, las tiene asignadas el rol de ejecución de cada función. Rotan solas y nadie las ve._ c) Nueva infraestructura, el mismo código de siempre Esta es la parte que más me gusta. Nada de lo que ya escribieron se entera del cambio: // Un job SendWelcomeEmail::dispatch($user->uuid); // Un mail encolado Mail::to($user)->queue(new WelcomeMail($user)); // Una notificación que implementa ShouldQueue $user->notify(new VerifyEmail); Todo eso va a SQS, y una Lambda lo procesa. Los listeners ShouldQueue, los jobs encadenados... casi todo sigue funcionando igual. Otro punto a favor para la abstracción de Laravel 💪. > Importante: El driver SQS de Laravel limita el delay() / #[Backoff] hasta 900 segundos (15 minutos), que es el límite de SQS [^6]. > Si necesitan varias colas _(una para emails urgentes, otra con menos concurrencia para no reventar una API externa, una FIFO para procesar en orden)_, cada una es un construct queue adicional, y desde Laravel 13.26 se enrutan con Queue::forward() [^7]. --- 4. ¿Qué Pasa Realmente Cuando Despachan un Job? Hasta acá todo parece magia: configuramos un bloque en serverless.yml, hicimos dispatch() y el job se ejecutó. Pero en producción la magia dura hasta el primer incidente. Veamos paso a paso _quién_ hace _qué_, desde que el mensaje entra a SQS hasta que desaparece. a) Laravel despacha, SQS recibe Cuando llaman a SendWelcomeEmail::dispatch($user->uuid), Laravel serializa el job y el driver sqs ejecuta una llamada SendMessage a la API de SQS para dejarlo en la cola. Y listo; nuestra Lambda web ya cumplió su parte y puede terminar su ejecución. > _El cuerpo del mensaje es el JSON de siempre (displayName, job, data, uuid, attempts). Para Laravel, SQS es solo otro buzón donde depositar ese mismo sobre._ b) SQS avisa, Lambda despierta El _event source mapping_ que creó Lift hace el _long polling_ por nosotros [^8]: AWS mantiene un proceso interno consultando la cola y, apenas detecta mensajes, invoca a nuestra función Lambda worker con un lote de hasta batchSize mensajes _(Lift lo deja en 1 por defecto; ya veremos por qué y cuándo cambiarlo)_. El polling sigue existiendo; la diferencia es que, en vez del proceso queue:work, ahora AWS se encarga de hacerlo. > Importante: Ese polling de AWS cuenta como llamadas ReceiveMessage en SQS, incluso con la cola vacía. Por suerte, la capa gratuita incluye 1 millón de requests mensuales [^9]: con la cola en reposo, ese chequeo constante genera ~21.000 requests por día _(~630.000 al mes)_, por lo que no pagan nada. c) Bref recibe el lote, Laravel ejecuta el job Cuando Lambda despierta, le entrega el evento de SQS al QueueHandler de Bref [^10]. Si espiamos su método handleSqs() _(simplificado)_ se ve exactamente qué pasa con cada mensaje: public function handleSqs(SqsEvent $event, Context $context): void { $worker = $this->container->makeWith(Worker::class, [ 'resetScope' => fn () => $this->resetLaravel(), // ... ]); foreach ($event->getRecords() as $sqsRecord) { $timeout = $this->calculateJobTimeout($context->getRemainingTimeInMillis()); $worker->runSqsJob( $job = $this->marshalJob($sqsRecord), $this->connection, $this->gatherWorkerOptions($timeout), ); if (! $job->hasFailed() && ! $job->isDeleted()) { $job->delete(); } } } Tres detalles clave en esas pocas líneas: 1. Ese Worker es el de Laravel. Literalmente extiende de Illuminate\Queue\Worker [^11]. Bref no reinventó cómo correr un job: le pasa cada mensaje al mismo motor que usa queue:work. Consecuencia directa: #[Tries], #[Backoff], $timeout, failed(), middlewares, ShouldBeUnique, retryUntil() y los eventos JobProcessing/JobProcessed/JobFailed... todo funciona igual que en un servidor tradicional. No hay una tabla de _"features soportadas"_ que consultar. 2. Entre job y job se limpia el estado. Ese callback resetLaravel() vacía el contexto de logs, olvida las instancias _scoped_ del contenedor y limpia las facades resueltas antes de cada ejecución. ¿Por qué? Por la misma razón que vimos con Octane en el segundo capítulo: la instancia de Lambda _(y su proceso PHP)_ sobrevive entre un job y el siguiente. Sin esa limpieza, el segundo job heredaría la basura en memoria del primero. 3. El borrado en SQS es explícito. Solo si el job terminó sin fallar, Bref llama a $job->delete() para quitarlo de la cola. Si falló o ya fue eliminado _(o liberado de vuelta a la cola, como veremos enseguida)_, no lo toca. d) El timeout de Lambda vs. el $timeout del job ¿Vieron ese calculateJobTimeout($context->getRemainingTimeInMillis()) en el loop? Bref no usa un timeout fijo: consulta cuántos milisegundos le quedan de vida a la invocación de Lambda _(menos 1 segundo de margen)_ y le asigna ese tiempo al job. Con ese valor, el Worker programa un pcntl_alarm() [^11]: si el job se pasa, suena la alarma, el proceso termina con exit(1) y la invocación falla. El job solo se marca como fallido _(y va a failed_jobs)_ si definieron #[FailOnTimeout] o $failOnTimeout = true. Dos implicaciones prácticas: - El timeout del worker en serverless.yml es, en la práctica, el techo del job más largo que pueden procesar. Configúrenlo pensando en su tarea más pesada. - Con un batchSize mayor a 1, todos los jobs del lote comparten ese mismo reloj: si procesan tareas largas, conviene mantener un lote pequeño _(o dejarlo en 1)_. > Ojo con #[Timeout] / $timeout: Si definen un timeout propio en la clase del job, Laravel pisa el valor calculado por Bref. Si ese número supera el tiempo que le queda a Lambda, AWS matará la invocación en seco antes de que suene la alarma de PHP. Recomendación: usen un valor bastante menor al timeout del worker, sobre todo con batchSize mayor a 1, donde los últimos jobs del lote arrancan con menos tiempo disponible. --- 5. ¿Quién Se Hace Cargo Cuando Algo Explota? A todos nos gusta ver cuando el camino feliz se cumple al pie de la letra: el job entra, se ejecuta y se borra de SQS. Pero en el mundo real las APIs externas se caen, las validaciones fallan y los procesos se quedan sin memoria. > Seguramente se estarán preguntando: _"Si Lift ya nos creó una Dead Letter Queue en SQS y Laravel ya tiene su propia tabla failed_jobs, ¿acaso no están haciendo lo mismo?"_ La respuesta es no. Cuando combinamos el Worker de Laravel con SQS y Lambda, aparecen dos mecanismos de reintento y fallo conviviendo en el mismo stack: uno del lado de nuestra aplicación y otro del lado de la infraestructura de AWS. Y aunque suenen parecidos, atacan problemas completamente distintos. a) Los reintentos los maneja Laravel, no SQS Empecemos por el lado de nuestra aplicación. Imaginen un job común y corriente configurado con reintentos y backoff: use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\Tries; #[Tries(3)] #[Backoff(30)] final class SendWelcomeEmail implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; public function __construct(public readonly string $user_uuid) {} public function handle(): void { // ... si esto lanza una excepción, Laravel reintenta en 30 segundos, hasta 3 veces } public function failed(Throwable $e): void { // ... agotados los 3 intentos, Laravel llama acá } } Cuando handle() lanza una excepción, el Worker de Laravel hace lo de siempre: si quedan intentos, llama a $job->release(30). Y acá está el truco. El SqsJob nativo de Laravel simplemente modifica el _visibility timeout_ del mensaje en SQS, pero Bref lo sobreescribe y hace otra cosa [^12]: public function release($delay = 0) { $this->released = true; $payload = $this->payload(); $payload['attempts'] = ($payload['attempts'] ?? 0) + 1; // borra el mensaje original... $this->sqs->deleteMessage([ 'QueueUrl' => $this->queue, 'ReceiptHandle' => $this->job['ReceiptHandle'], ]); // ...y envía una copia nueva, con el contador de intentos y el delay del backoff $this->sqs->sendMessage([ 'QueueUrl' => $this->queue, 'MessageBody' => json_encode($payload), 'DelaySeconds' => $this->secondsUntil($delay), ]); } > No es un _"reenviar"_. Es borrar el correo y redactar uno nuevo, idéntico, con [Intento 2] en el asunto y programado para dentro de 30 segundos. ¿Por qué? Porque así el contador de intentos viaja dentro del payload _(attempts() lo lee de ahí, no del ApproximateReceiveCount de SQS)_, y el backoff se traduce a un DelaySeconds real. Resultado: #[Tries(3)] y #[Backoff(30)] se comportan exactamente como en un servidor. Bref eligió deliberadamente la semántica de Laravel por sobre la de SQS [^3]. > Si no definen #[Tries] / $tries, el handler de Bref usa 3 por defecto [^13]. Y si usan backoff exponencial _(#[Backoff([10, 60, 300])])_, también funciona: cada reenvío lleva el delay que corresponde a ese intento. b) El efecto secundario: SQS nunca se entera del fallo Cuando en handle() se lanza una excepción, el Worker de Laravel la atrapa en su propio try/catch: la reporta en los logs, decide si reintenta o da el job por perdido, y sigue de largo. Como esa excepción nunca llega hasta handleSqs(), para AWS Lambda la función terminó sin errores. Esto significa que el mecanismo nativo de reintentos de SQS _(visibility timeout → maxReceiveCount → DLQ)_ jamás se entera de que un job falló por una excepción de PHP. Ni en el primer intento, ni en el tercero. > _Laravel es ese compañero que en la daily dice "todo bien" con producción en llamas. Y SQS le cree._ c) Primera capa: cuando el job agota sus intentos en Laravel Cuando un job falla por tercera vez y agota sus intentos, Laravel se rinde: ejecuta el método failed() de la clase y registra el fallo usando el driver configurado en config/queue.php _(queue.failed)_. El problema es que, por defecto, Laravel intenta guardar ese registro en una tabla failed_jobs de una base de datos SQL... de la cual nos despedimos en el capítulo anterior. Sin base relacional, la salida rápida es desactivarlo completamente con QUEUE_FAILED_DRIVER: null [^3]: la excepción igual queda en los logs de CloudWatch, pero el job no se guarda en ningún lado. No lo recomiendo. La buena noticia es que Laravel ya incluye soporte nativo para guardar failed_jobs en DynamoDB [^14]. Solo debemos ajustar config/queue.php: 'failed' => [ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), // Al utilizar driver=dynamodb no es necesario especificar 'database' // 'database' => env('DB_CONNECTION', 'sqlite'), 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'token' => env('AWS_SESSION_TOKEN'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_FAILED_JOBS_TABLE', 'failed_jobs'), ], provider: environment: QUEUE_FAILED_DRIVER: dynamodb DYNAMODB_FAILED_JOBS_TABLE: !Ref FailedJobsTable Y declaramos la tabla dentro de resources en serverless.yml. Laravel usa application como partition key _(el nombre de la app)_ y uuid como sort key: FailedJobsTable: Type: AWS::DynamoDB::Table Properties: TableName: failed_jobs BillingMode: PAY_PER_REQUEST AttributeDefinitions: - { AttributeName: application, AttributeType: S } - { AttributeName: uuid, AttributeType: S } KeySchema: - { AttributeName: application, KeyType: HASH } - { AttributeName: uuid, KeyType: RANGE } TimeToLiveSpecification: AttributeName: expires_at Enabled: true > Así como lo hicimos en el capítulo anterior con la tabla de caché y sesiones, utilizamos TimeToLiveSpecification; Laravel guarda cada job fallido con fecha de vencimiento _(expires_at, 7 días por defecto [^14])_. Este driver ni siquiera implementa queue:flush: DynamoDB se encarga de limpiar los registros caducados. Por último, le damos permisos sobre la tabla al rol de ejecución: provider: iam: role: statements: - Effect: Allow Resource: !GetAtt FailedJobsTable.Arn Action: - dynamodb:PutItem - dynamodb:Query - dynamodb:GetItem - dynamodb:DeleteItem Para listar o reintentar los jobs fallidos, como en Lambda no hay SSH, usamos el puente bref:cli que vimos en el segundo capítulo: serverless bref:cli --args="queue:failed" serverless bref:cli --args="queue:retry all" > _"Entonces, ¿para qué está la DLQ que creó Lift?"_ d) Segunda capa: cuando falla la infraestructura Un mensaje solo llega a la _Dead Letter Queue_ cuando falla la invocación completa de Lambda, sin darle oportunidad a Laravel de atrapar el error. Por ejemplo: - Un job superó el tiempo que le quedaba a la invocación (el pcntl_alarm se activó y la ejecución salió con exit(1)). - Se quedó sin memoria _(OOM)_. - Un error fatal de PHP fuera del alcance del Worker. - Un fallo de arranque: una extensión faltante, un error de configuración, un composer install que dejó algo afuera. En cualquiera de estos escenarios, Lambda muere antes de que Bref pueda borrar el mensaje, así que entra en acción el mecanismo nativo de SQS: 1. Espera (_visibility timeout_): el mensaje queda oculto hasta que vence su tiempo de visibilidad. Lift lo configura automáticamente en 6 veces el timeout del worker siguiendo la recomendación de AWS [^15] _(con los 60 segundos de nuestro ejemplo, reaparece a los 6 minutos)_. 2. Reintento: una vez visible de nuevo, SQS vuelve a entregárselo a Lambda. 3. Rumbo a la DLQ: si la invocación falla 3 veces seguidas _(maxRetries por defecto en Lift)_, SQS retira el mensaje de la cola principal y lo guarda en la Dead Letter Queue hasta por 14 días, evitando que se pierda. Para inspeccionar qué cayó en la DLQ o volver a encolarlo sin entrar a la consola de AWS, Lift nos da estos comandos [^16]: serverless jobs:logs --tail # logs del worker, en vivo serverless jobs:failed # qué hay en la DLQ serverless jobs:failed:retry # devolver todo a la cola principal serverless jobs:failed:purge # borrar todo y seguir con la vida Claro que nadie quiere ejecutar serverless jobs:failed todos los días por las dudas. Si agregan la propiedad alarm al construct, Lift configura automáticamente una alarma en CloudWatch que les envía un email apenas cae un mensaje en la DLQ [^4]: constructs: jobs: type: queue # ... alarm: alertas@mi-dominio.com # alerta por email cuando la DLQ recibe mensajes > Luego del primer serverless deploy, AWS les enviará un correo de confirmación al email configurado en alarm _(no olviden confirmarlo para activar las alertas)_. e) Las dos capas, lado a lado | | Primera capa: failed_jobs | Segunda capa: DLQ de SQS | |-------------------------|-----------------------------------------|--------------------------------------------------| | ¿Qué falló? | Nuestro código: excepción en handle() | La infraestructura: timeout, OOM, error fatal | | ¿Quién reintenta? | Laravel (#[Tries], #[Backoff]) | SQS (_visibility timeout_, maxRetries) | | ¿Qué se reintenta? | Ese job, tras el backoff configurado | El job en curso y el resto del lote (6× timeout) | | ¿Dónde termina? | Tabla failed_jobs (DynamoDB, 7 días) | Dead Letter Queue (SQS, 14 días) | | ¿Cómo lo reintento? | bref:cli --args="queue:retry ..." | serverless jobs:failed:retry | | ¿Cómo me entero? | failed(), CloudWatch, queue:failed | Alerta por email (alarm:) | Fíjense en la fila ¿Qué se reintenta? de la segunda capa. Cuando Lambda muere a mitad de un lote _(por timeout u OOM)_, los jobs que ya habían terminado fueron eliminados uno a uno con $job->delete(), así que no se repiten. SQS solo vuelve a entregar lo que quedó sin borrar: el job que estaba a mitad de ejecución y los que venían detrás en la fila. El peligro está en ese job que quedó a medias: cuando SQS lo reintente, va a correr de nuevo desde el principio. Si ya había disparado un cobro o enviado un email un segundo antes de que muriera Lambda y no es idempotente, lo hará por segunda vez _(y van a aprender la palabra "reembolso" antes de lo que quisieran)_. > Regla de oro: diseñen siempre jobs idempotentes. --- 6. Ajustando las Perillas del Worker para Producción Las 8 líneas de la sección 3 alcanzan para arrancar, pero ahora que conocemos qué pasa debajo del capó podemos ajustar las perillas para producción. Así se ve una configuración pensada para el mundo real: constructs: jobs: type: queue maxRetries: 5 # maxReceiveCount de la cola: AWS recomienda >= 5 maxConcurrency: 20 # el freno "limpio": Lambda deja de leer, los mensajes esperan worker: handler: Bref\LaravelBridge\Queue\QueueHandler runtime: php-85 layers: - ${bref-extra:gd-php-85} reservedConcurrency: 25 # tope duro y reserva; siempre >= maxConcurrency timeout: 300 batchSize: 10 a) timeout: el techo del job más largo Define tanto el tiempo máximo que puede durar una invocación como el _visibility timeout_ de la cola en SQS _(que Lift multiplica ×6)_. En mi caso lo llevé a 300 segundos porque el worker procesa imágenes pesadas, y redimensionar una foto de 20 MB lleva su tiempo. Si sus jobs solo envían emails o llaman a una API, con 30 o 60 segundos van sobrados. b) batchSize: menos invocaciones, más responsabilidad Lift lo deja en 1 por defecto para simplificar el manejo de errores [^17]. Subirlo a 10 significa que una ráfaga de 500 jobs se procesa en 50 invocaciones en lugar de 500, reduciendo _cold-starts_ y costos. A cambio, recuerden las dos condiciones que vimos antes: todos los jobs del lote comparten el mismo reloj de Lambda, y nuestros jobs deben ser idempotentes por si una invocación muere a mitad del lote. > Existe también maxBatchingWindow: cuántos segundos espera SQS para juntar un lote completo antes de invocar. Cero por defecto. Si no les molesta que un job espere unos segundos a que lleguen compañeros, pueden subirlo. c) reservedConcurrency vs maxConcurrency Si llegan 1.000 jobs de golpe, Lambda escala rápido hasta cientos de invocaciones simultáneas. Y eso suena hermoso hasta que esos 1.000 jobs pegan al mismo tiempo contra una API externa con _rate limit_, contra nuestro servidor SMTP, o contra la misma DynamoDB. Hay dos formas de ponerle un techo: - reservedConcurrency _(propiedad de la función)_: la función nunca ejecuta más de N instancias simultáneas. Pero además reserva esas N de la cuota total de concurrencia de la cuenta, aunque estén sin uso [^18]. - maxConcurrency _(propiedad del construct de Lift)_: limita cuántas invocaciones puede disparar el event source mapping de esta cola, sin reservar nada [^19]. Lo ideal es combinarlos: maxConcurrency como freno limpio _(los mensajes esperan en la cola sin gastar intentos)_ y reservedConcurrency apenas por encima, como red de seguridad por si un bug despacha jobs en loop. > Ojo: si usan solo reservedConcurrency, todo lo que supere ese límite cae en _throttling_, y cada rechazo cuenta como un intento en SQS. Con maxRetries: 3 _(el default de Lift)_, un pico de tráfico puede mandar jobs directo a la DLQ sin haberse ejecutado ni una vez. Por eso AWS recomienda subir maxRetries a por lo menos 5 [^20]. d) memorySize: más memoria también es más CPU Como vimos en el segundo capítulo, en Lambda la potencia de CPU escala de forma proporcional a la memoria asignada. Un job pesado que tarda 10 segundos con 512 MB puede resolverse en 3 segundos con 1.769 MB; y como AWS cobra por GB-segundo, darle más memoria muchas veces termina saliendo más barato. Si su worker hace trabajo intensivo, pueden asignarle su propio memorySize y medir el punto dulce con aws-lambda-power-tuning [^21]. e) Layers a medida: extensiones solo donde hacen falta ¿Notaron el layer ${bref-extra:gd-php-85} en la configuración? Mi worker necesita la extensión GD para procesar imágenes, pero la función HTTP web no. Al ser funciones Lambda separadas, cada una carga únicamente las extensiones que usa: las peticiones web siguen livianas, y el peso extra solo lo carga el worker en segundo plano. f) ¿Hace falta un warmer en el worker? En el segundo capítulo implementamos un _warmer_ cada 5 minutos para evitar el _cold-start_ en las peticiones web. ¿Necesita uno el worker? Para nada. Un _cold-start_ de 200 ms en una ruta HTTP lo sufre un usuario mirando cómo carga la pantalla; en una cola en segundo plano, 200 ms extra no le quitan el sueño a nadie. --- 7. Lo que No Te Cuentan los Tutoriales (y Descubres en Producción) Cosas que no están en la documentación _(o que estaban en letra chica y aprendí por las malas)_: a) SerializesModels y las claves compuestas de DynamoDB ¿Notaron que en SendWelcomeEmail _(sección 5.a)_ pasé $user_uuid como string en lugar del modelo User? No fue casualidad. SerializesModels guarda solo getKey() _(PK)_ e intenta restaurar el modelo con una consulta SQL tradicional que no incluye la SK ni soporta DynamoDB. Para sus propios jobs, lo más simple es pasar el uuid al constructor y buscar el modelo dentro de handle(). Ahora bien, si usan notificaciones encoladas _($user->notify())_, Laravel serializa el modelo User sí o sí: ahí no queda otra que sobreescribir getQueueableId() y newQueryForRestoration() en su modelo para codificar y reconstruir PK + SK. b) Bus::batch() también necesita dónde guardar su progreso Por defecto busca la tabla relacional job_batches, pero Laravel incluye soporte nativo para DynamoDB: solo deben crear la tabla job_batches _(partition key application, sort key id)_, darle permisos IAM y configurar queue.batching.driver en dynamodb [^22]. c) El entorno local no necesita tocar AWS Como el código de nuestros jobs no cambia según el driver, en su máquina pueden seguir usando QUEUE_CONNECTION=database _(o sync)_ con el queue:work de toda la vida, o bien emular SQS localmente con herramientas como MiniStack [^23] o LocalStack [^24]. QUEUE_CONNECTION=sqs apuntando a AWS solo hace falta en serverless.yml. d) El límite es de 1 MiB por mensaje Es el tope de SQS [^25]. Los jobs deben transportar identificadores livianos, no archivos en base64 ni colecciones de 10.000 elementos. Si están cerca de ese límite, el diseño les está pidiendo a gritos que el job busque los datos cuando corre en vez de recibirlos en el payload. e) La limpieza del SDK de AWS del segundo capítulo Si usaron aws/aws-sdk-php con removeUnusedServices en composer.json para reducir el peso del deploy, asegúrense de agregar Sqs a la lista de servicios permitidos: "extra": { "aws/aws-sdk-php": [ "DynamoDb", "S3", "Sqs" ] } ¿Por qué es tan fácil olvidarlo? Porque los tests corren con QUEUE_CONNECTION=sync o Queue::fake(), así que el CI pasa todo en verde sin instanciar jamás SqsClient... hasta que el primer dispatch() real en producción explota porque la clase fue borrada. --- 8. Costos: El Worker que Solo Cobra Cuando Trabaja Actualicemos la cuenta, como en cada capítulo. - Antes (EC2): el worker vivía en una de las instancias t3.small del primer capítulo _(~$15/mes por instancia)_. Un proceso queue:work residente con RAM reservada y supervisord encendido las 24 horas, aunque la cola pasara el 90% del día vacía. - Ahora (SQS + Lambda): - SQS: el primer millón de requests mensuales es gratis; después cuesta \$0.40 por millón [^26]. Sumando los ~630.000 requests del polling en reposo y 3 requests por cada job _(SendMessage, ReceiveMessage y DeleteMessage)_, pueden procesar hasta ~120.000 jobs al mes por $0.00. - Lambda: la capa gratuita perpetua incluye 1 millón de invocaciones y 400.000 GB-segundos al mes. Sin jobs en la cola, el cómputo cuesta exactamente cero. Pongámoslo en números para un mes con 100.000 jobs _(de ~500 ms promedio con 512 MB de RAM)_: | Concepto | Dentro del Free Tier | Tarifa Plena _(sin Free Tier)_ | |-----------------------------|-------------------------:|-----------------------------------:| | Polling SQS (~630k req/mes) | $0.00 | $0.25 | | Mensajes SQS (300k req/mes) | $0.00 | $0.12 | | Lambda Worker (100k jobs) | $0.00 | $0.44 | | Total mensual | $0.00 | ~$0.81 | > _¡Incluso sin la capa gratuita, procesar 100.000 jobs cuesta menos de un dólar al mes!_ Y lo más importante: con DynamoDB en PAY_PER_REQUEST, Lambda facturando por milisegundo y SQS por request, casi ningún componente del sistema de colas cobra tarifa fija por existir _(a excepción del polling de SQS, que en mi caso cae dentro del free tier)_. --- 9. ¿Qué Sigue? Hoy jubilamos definitivamente a supervisord y al proceso queue:work que corría las 24 horas: los reemplazamos por una cola SQS y un worker en Lambda que solo despierta cuando hay trabajo. Vimos que Bref reutiliza el mismo Worker de Laravel _(así que nuestros jobs no cambian)_, cómo hace su truco en release() para conservar los reintentos y el backoff, y cómo conviven failed_jobs en DynamoDB con la _Dead Letter Queue_ de SQS. Pero quedó un cabo suelto entre el capítulo anterior y este. En la sección 6 les conté que mi worker carga la extensión GD para redimensionar imágenes; ahora bien, si en el capítulo anterior vimos que los archivos viajan directo del navegador a S3 sin pasar por nuestra aplicación... ¿quién le avisa a Laravel que terminó de subirse un archivo para encolar ese job? En el próximo capítulo vamos a cerrar ese círculo con S3 Event Notifications, y veremos por qué la solución _no_ es conectar S3 directo a la cola que acabamos de crear. --- Una pregunta para ustedes: ¿con qué corren sus colas de Laravel hoy en producción, o cuál fue la peor jugada que les hizo un worker a las 3 AM? Los leo en los comentarios: las mejores historias de producción salen de ahí, no de los post-mortems. [^1]: docs.aws.amazon.com [^2]: docs.aws.amazon.com [^3]: bref.sh [^4]: github.com/getlift/lift/blob/master/docs/queue.md [^5]: github.com/getlift/lift/blob/master/docs/permissi… [^6]: docs.aws.amazon.com [^7]: bref.sh [^8]: docs.aws.amazon.com [^9]: aws.amazon.com [^10]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^11]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^12]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^13]: github.com/brefphp/laravel-bridge/blob/a62196d5cb… [^14]: laravel.com [^15]: github.com/getlift/lift/blob/master/docs/queue.md [^16]: github.com/getlift/lift/blob/master/docs/queue.md [^17]: github.com/getlift/lift/blob/master/docs/queue.md [^18]: docs.aws.amazon.com [^19]: github.com/getlift/lift/blob/master/docs/queue.md [^20]: docs.aws.amazon.com [^21]: alexcasalboni/aws-lambda-power-tuning [^22]: laravel.com [^23]: ministackorg/ministack [^24]: localstack/localstack [^25]: docs.aws.amazon.com [^26]: aws.amazon.com
Trying MiniStack and StackPort Introduction I tried MiniStack, a free local AWS emulator, and StackPort, a browser-based viewer for its resources. {% embed ministackorg/ministack %} {% embed DaviReisVieira/stackport %} This article builds the following pipeline: - EventBridge Scheduler invokes a Lambda function on a schedule - The Lambda function calls an API and publishes the result to SNS - SNS triggers a second Lambda function, which posts to a Slack Incoming Webhook - The API key and the Webhook URL are stored as SecureString parameters in SSM Parameter Store What Are MiniStack and StackPort? MiniStack MiniStack is a local AWS emulator under the MIT license. You can use more than 60 AWS services directly from the AWS CLI, boto3, Terraform, and CDK. {% embed ministack.org %} StackPort StackPort is a tool for browsing the resources of local emulators and real AWS accounts in a browser. It connects to emulators other than MiniStack (such as LocalStack) and to real AWS accounts. {% embed stackport.cloud %} StackPort lets you browse the resources of 35 services. Eight of them have dedicated screens: S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs, and Secrets Manager. For example, CloudWatch Logs lets you drill down from log groups to log streams to log events. The other services (such as SSM in this article) are shown as a searchable resource table with a JSON detail view. Note that StackPort does not support EventBridge Scheduler. The events service only shows EventBridge rules and event buses, so I check the schedule with the CLI. Architecture Locally, I run a mock server (mock-api) in place of the external API. For Slack, I use a real Incoming Webhook, with separate channels for the local and AWS environments. !Local environment architecture Two things differ between the local environment and real AWS: - External API: mock-api locally, API Gateway (MOCK integration) on real AWS. Both return GET /v1/status - Slack destination: #notify-demo-local locally, #notify-demo-aws on real AWS The directory structure is as follows. Terraform is split into a shared module (modules/notify) and one directory per environment (environments/local and environments/aws). Each environment has its own state. code/ ├── lambda/ # Shared: the same code runs locally and on real AWS │ ├── fetch_status/ │ │ └── app.py │ └── notify_slack/ │ └── app.py ├── mock-api/ # Local only: mock server standing in for the external API │ ├── Dockerfile │ └── server.py ├── scripts/ │ ├── put-parameters-aws.sh # AWS only: registers parameters in the real SSM │ └── put-parameters.sh # Local only: registers parameters in MiniStack's SSM ├── terraform/ │ ├── environments/ │ │ ├── aws/ # For real AWS: points at real AWS │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ └── local/ # For local: points at MiniStack │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── providers.tf │ │ ├── terraform.tfvars │ │ ├── terraform.tfvars.example │ │ └── variables.tf │ └── modules/ │ └── notify/ # Shared: Lambda, SNS, Scheduler, IAM, and more │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── versions.tf └── docker-compose.yml # Local only: starts MiniStack, StackPort, and mock-api Building the Local Environment You need Docker (Docker Desktop, OrbStack, and so on), Terraform, and the AWS CLI. Step 1: Start MiniStack, StackPort, and the Mock Server Start three containers with docker-compose.yml. services: ministack: image: ministackorg/ministack:latest ports: - "4566:4566" environment: - MINISTACK_REGION=ap-northeast-1 healthcheck: test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')", ] interval: 5s timeout: 3s retries: 5 stackport: image: davireis/stackport:latest ports: - "8080:8080" environment: - AWS_ENDPOINT_URL=http://ministack:4566 - AWS_REGION=ap-northeast-1 - AWS_ACCESS_KEY_ID=test - AWS_SECRET_ACCESS_KEY=test healthcheck: test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')", ] interval: 10s timeout: 3s retries: 3 restart: unless-stopped depends_on: ministack: condition: service_healthy # Mock server standing in for the external API mock-api: build: ./mock-api ports: - "8000:8000" environment: - EXPECTED_API_KEY=local-dummy-api-key The three containers are: - ministack: the local AWS emulator. It accepts AWS API calls on port 4566 - stackport: a web UI for browsing MiniStack's resources. It opens on port 8080 - mock-api: a mock server standing in for the external API. It runs on port 8000 mock-api is written with only the Python standard library. It has one endpoint that validates the x-api-key header and returns a status. def do_GET(self): if self.path == "/v1/status": if self.headers.get("x-api-key") != EXPECTED_API_KEY: return self._send(403, {"message": "Forbidden"}) return self._send(200, {"status": "ok"}) return self._send(404, {"message": "not found"}) Start the containers. cd code docker compose up -d --build Open http://localhost:8080 in a browser to see the StackPort dashboard. !StackPort dashboard Step 2: Register SecureString Parameters in SSM Parameter Store I register the API key and the Webhook URL outside Terraform. If you create them as aws_ssm_parameter resources in Terraform, the values remain in the state file in plain text. Write-only arguments (value_wo) in Terraform 1.11 and later keep them out of the state, but I don't use them here. I prepared a script (scripts/put-parameters.sh) to register them. #!/usr/bin/env bash set -euo pipefail : "${SLACK_WEBHOOK_URL:?Set SLACK_WEBHOOK_URL}" export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test export AWS_DEFAULT_REGION=ap-northeast-1 ENDPOINT=http://localhost:4566 aws --endpoint-url="$ENDPOINT" ssm put-parameter \ --name /notify-demo/api-key \ --type SecureString \ --value local-dummy-api-key \ --overwrite aws --endpoint-url="$ENDPOINT" ssm put-parameter \ --name /notify-demo/slack-webhook-url \ --type SecureString \ --value "$SLACK_WEBHOOK_URL" \ --overwrite For the Webhook URL, I use an Incoming Webhook issued for a Slack channel dedicated to local testing (#notify-demo-local). SLACK_WEBHOOK_URL=<Webhook URL of the local channel> ./scripts/put-parameters.sh Running the script adds the parameters. !StackPort SSM parameter list The displayed value is ENCRYPTED: followed by a base64 string. It is not encrypted with KMS, and anyone can decode it back to the original value, so be careful when sharing screenshots. !A SecureString value as seen in StackPort Step 3: Write the Lambda Code This is the Lambda function that calls the API (fetch_status). It gets the API key from SSM and publishes the API response to SNS. import json import os import urllib.request import boto3 ssm = boto3.client("ssm") sns = boto3.client("sns") API_BASE_URL = os.environ["API_BASE_URL"] API_KEY_PARAM = os.environ["API_KEY_PARAM"] RESULT_TOPIC_ARN = os.environ["RESULT_TOPIC_ARN"] _api_key = None def get_api_key(): # Fetch from SSM only on a cold start; use the cache afterwards global _api_key if _api_key is None: res = ssm.get_parameter(Name=API_KEY_PARAM, WithDecryption=True) _api_key = res["Parameter"]["Value"] return _api_key def handler(event, context): req = urllib.request.Request( f"{API_BASE_URL}/v1/status", headers={"x-api-key": get_api_key()}, ) # 4xx/5xx raise HTTPError, which makes the Lambda invocation fail with urllib.request.urlopen(req, timeout=10) as res: result = json.loads(res.read()) print(f"API status: {result['status']}") sns.publish( TopicArn=RESULT_TOPIC_ARN, Subject="API status check", Message=json.dumps(result, ensure_ascii=False), ) return result This is the Lambda function for Slack notifications (notify_slack). It extracts the SNS message and posts it to the Webhook. def handler(event, context): for record in event["Records"]: result = json.loads(record["Sns"]["Message"]) now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") text = f":white_check_mark: API status: {result['status']} ({now})" req = urllib.request.Request( get_webhook_url(), data=json.dumps({"text": text}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=10): pass print(text) Neither piece of code contains any MiniStack-specific endpoint configuration. MiniStack injects AWS_ENDPOINT_URL into the Lambda execution environment, so boto3 automatically points at MiniStack. The same code can be deployed to real AWS as is. Step 4: Create the Resources with Terraform The resources live in the shared module (terraform/modules/notify/main.tf). I don't show every resource. Instead, I excerpt the points I designed to work on both MiniStack and real AWS. The external API URL is passed through the module variable api_base_url. resource "aws_lambda_function" "fetch_status" { function_name = local.fetch_name role = aws_iam_role.fetch_status.arn runtime = "python3.14" handler = "app.handler" filename = data.archive_file.fetch_status.output_path source_code_hash = data.archive_file.fetch_status.output_base64sha256 timeout = 30 environment { variables = { API_BASE_URL = var.api_base_url API_KEY_PARAM = local.api_key_param RESULT_TOPIC_ARN = aws_sns_topic.result.arn } } depends_on = [aws_cloudwatch_log_group.fetch_status] } terraform/environments/ contains one directory per environment (local and aws). Each has a main.tf that calls the shared module and its own environment-specific settings. The only differences are providers.tf, which decides the connection target, and terraform.tfvars, which decides the external API URL. Because the directories are separate, the state is also separate for each environment. In terraform/environments/local/providers.tf, the provider points only the services I use at MiniStack. provider "aws" { access_key = "test" secret_key = "test" region = "ap-northeast-1" skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { iam = "http://localhost:4566" lambda = "http://localhost:4566" logs = "http://localhost:4566" scheduler = "http://localhost:4566" sns = "http://localhost:4566" ssm = "http://localhost:4566" sts = "http://localhost:4566" } } These are the settings that make it work with MiniStack: - access_key, secret_key: dummy values (test) - region: the region to deploy to - skip_credentials_validation: skips validating credentials through the STS API - skip_metadata_api_check: skips the check against the AWS metadata API - skip_requesting_account_id: skips retrieving the account ID - endpoints: points each service at MiniStack. Specify only the services you use The local directory (terraform/environments/local/main.tf) calls the module. The external API URL is passed through the variable api_base_url. module "notify" { source = "../../modules/notify" region = "ap-northeast-1" api_base_url = var.api_base_url } The variable's value goes in terraform/environments/local/terraform.tfvars. Because this file is not managed in Git, create it by copying the template (terraform.tfvars.example). The Lambda function runs inside the MiniStack container, so it reaches mock-api by its Compose service name. api_base_url = "http://mock-api:8000" Apply it. cd terraform/environments/local cp terraform.tfvars.example terraform.tfvars # Edit terraform.tfvars if needed terraform init terraform apply Here is the result. The resources are created in MiniStack's default account ID (000000000000). $ terraform apply module.notify.data.archive_file.notify_slack: Reading... module.notify.data.archive_file.fetch_status: Reading... module.notify.data.archive_file.notify_slack: Read complete after 0s [id=68fb7c506afbeeab169fb65494efc63dd8acba90] module.notify.data.archive_file.fetch_status: Read complete after 0s [id=013b111cb8285884b651e501fd6f84ad373f787a] module.notify.data.aws_iam_policy_document.lambda_assume: Reading... module.notify.data.aws_caller_identity.current: Reading... module.notify.data.aws_iam_policy_document.lambda_assume: Read complete after 0s [id=2690255455] module.notify.data.aws_caller_identity.current: Read complete after 0s [id=000000000000] module.notify.data.aws_iam_policy_document.scheduler_assume: Reading... module.notify.data.aws_iam_policy_document.notify_slack: Reading... module.notify.data.aws_iam_policy_document.scheduler_assume: Read complete after 0s [id=644537899] module.notify.data.aws_iam_policy_document.notify_slack: Read complete after 0s [id=2364181876] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create <= read (data resources) Terraform will perform the following actions: # module.notify.data.aws_iam_policy_document.fetch_status will be read during apply # (config refers to values not yet known) <= data "aws_iam_policy_document" "fetch_status" { + id = (known after apply) + json = (known after apply) + minified_json = (known after apply) + statement { + actions = [ + "ssm:GetParameter", ] + resources = [ + "arn:aws:ssm:ap-northeast-1:000000000000:parameter/notify-demo/api-key", ] } + statement { + actions = [ + "sns:Publish", ] + resources = [ + (known after apply), ] } } # module.notify.data.aws_iam_policy_document.scheduler will be read during apply # (config refers to values not yet known) <= data "aws_iam_policy_document" "scheduler" { + id = (known after apply) + json = (known after apply) + minified_json = (known after apply) + statement { + actions = [ + "lambda:InvokeFunction", ] + resources = [ + (known after apply), ] } } # module.notify.aws_cloudwatch_log_group.fetch_status will be created + resource "aws_cloudwatch_log_group" "fetch_status" { + arn = (known after apply) + deletion_protection_enabled = (known after apply) + id = (known after apply) + log_group_class = (known after apply) + name = "/aws/lambda/notify-demo-fetch-status" + name_prefix = (known after apply) + region = "ap-northeast-1" + retention_in_days = 7 + skip_destroy = false + tags_all = (known after apply) } # module.notify.aws_cloudwatch_log_group.notify_slack will be created + resource "aws_cloudwatch_log_group" "notify_slack" { + arn = (known after apply) + deletion_protection_enabled = (known after apply) + id = (known after apply) + log_group_class = (known after apply) + name = "/aws/lambda/notify-demo-notify-slack" + name_prefix = (known after apply) + region = "ap-northeast-1" + retention_in_days = 7 + skip_destroy = false + tags_all = (known after apply) } # module.notify.aws_iam_role.fetch_status will be created + resource "aws_iam_role" "fetch_status" { + arn = (known after apply) + assume_role_policy = jsonencode( { + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" } }, ] + Version = "2012-10-17" } ) + create_date = (known after apply) + force_detach_policies = false + id = (known after apply) + managed_policy_arns = (known after apply) + max_session_duration = 3600 + name = "notify-demo-fetch-status-role" + name_prefix = (known after apply) + path = "/" + tags_all = (known after apply) + unique_id = (known after apply) + inline_policy (known after apply) } # module.notify.aws_iam_role.notify_slack will be created + resource "aws_iam_role" "notify_slack" { + arn = (known after apply) + assume_role_policy = jsonencode( { + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" } }, ] + Version = "2012-10-17" } ) + create_date = (known after apply) + force_detach_policies = false + id = (known after apply) + managed_policy_arns = (known after apply) + max_session_duration = 3600 + name = "notify-demo-notify-slack-role" + name_prefix = (known after apply) + path = "/" + tags_all = (known after apply) + unique_id = (known after apply) + inline_policy (known after apply) } # module.notify.aws_iam_role.scheduler will be created + resource "aws_iam_role" "scheduler" { + arn = (known after apply) + assume_role_policy = jsonencode( { + Statement = [ + { + Action = "sts:AssumeRole" + Condition = { + StringEquals = { + "aws:SourceAccount" = "000000000000" } } + Effect = "Allow" + Principal = { + Service = "scheduler.amazonaws.com" } }, ] + Version = "2012-10-17" } ) + create_date = (known after apply) + force_detach_policies = false + id = (known after apply) + managed_policy_arns = (known after apply) + max_session_duration = 3600 + name = "notify-demo-scheduler-role" + name_prefix = (known after apply) + path = "/" + tags_all = (known after apply) + unique_id = (known after apply) + inline_policy (known after apply) } # module.notify.aws_iam_role_policy.fetch_status will be created + resource "aws_iam_role_policy" "fetch_status" { + id = (known after apply) + name = (known after apply) + name_prefix = (known after apply) + policy = (known after apply) + role = (known after apply) } # module.notify.aws_iam_role_policy.notify_slack will be created + resource "aws_iam_role_policy" "notify_slack" { + id = (known after apply) + name = (known after apply) + name_prefix = (known after apply) + policy = jsonencode( { + Statement = [ + { + Action = "ssm:GetParameter" + Effect = "Allow" + Resource = "arn:aws:ssm:ap-northeast-1:000000000000:parameter/notify-demo/slack-webhook-url" }, ] + Version = "2012-10-17" } ) + role = (known after apply) } # module.notify.aws_iam_role_policy.scheduler will be created + resource "aws_iam_role_policy" "scheduler" { + id = (known after apply) + name = (known after apply) + name_prefix = (known after apply) + policy = (known after apply) + role = (known after apply) } # module.notify.aws_iam_role_policy_attachment.fetch_status_logs will be created + resource "aws_iam_role_policy_attachment" "fetch_status_logs" { + id = (known after apply) + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + role = "notify-demo-fetch-status-role" } # module.notify.aws_iam_role_policy_attachment.notify_slack_logs will be created + resource "aws_iam_role_policy_attachment" "notify_slack_logs" { + id = (known after apply) + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + role = "notify-demo-notify-slack-role" } # module.notify.aws_lambda_function.fetch_status will be created + resource "aws_lambda_function" "fetch_status" { + architectures = (known after apply) + arn = (known after apply) + code_sha256 = (known after apply) + filename = "./build/fetch_status.zip" + function_name = "notify-demo-fetch-status" + handler = "app.handler" + id = (known after apply) + invoke_arn = (known after apply) + last_modified = (known after apply) + memory_size = 128 + package_type = "Zip" + publish = false + qualified_arn = (known after apply) + qualified_invoke_arn = (known after apply) + region = "ap-northeast-1" + reserved_concurrent_executions = -1 + response_streaming_invoke_arn = (known after apply) + role = (known after apply) + runtime = "python3.14" + signing_job_arn = (known after apply) + signing_profile_version_arn = (known after apply) + skip_destroy = false + source_code_hash = "So5oKi3b/bEDekVIzzKxoKwo1aO2n/JdG+Ioj2nhtVw=" + source_code_size = (known after apply) + tags_all = (known after apply) + timeout = 30 + version = (known after apply) + environment { + variables = (known after apply) } + ephemeral_storage (known after apply) + logging_config (known after apply) + tracing_config (known after apply) } # module.notify.aws_lambda_function.notify_slack will be created + resource "aws_lambda_function" "notify_slack" { + architectures = (known after apply) + arn = (known after apply) + code_sha256 = (known after apply) + filename = "./build/notify_slack.zip" + function_name = "notify-demo-notify-slack" + handler = "app.handler" + id = (known after apply) + invoke_arn = (known after apply) + last_modified = (known after apply) + memory_size = 128 + package_type = "Zip" + publish = false + qualified_arn = (known after apply) + qualified_invoke_arn = (known after apply) + region = "ap-northeast-1" + reserved_concurrent_executions = -1 + response_streaming_invoke_arn = (known after apply) + role = (known after apply) + runtime = "python3.14" + signing_job_arn = (known after apply) + signing_profile_version_arn = (known after apply) + skip_destroy = false + source_code_hash = "M+ENHhkFRJqThFhPFiREbrEpiqqEf0cJLzdvTNzrMUY=" + source_code_size = (known after apply) + tags_all = (known after apply) + timeout = 30 + version = (known after apply) + environment { + variables = { + "TZ" = "Asia/Tokyo" + "WEBHOOK_URL_PARAM" = "/notify-demo/slack-webhook-url" } } + ephemeral_storage (known after apply) + logging_config (known after apply) + tracing_config (known after apply) } # module.notify.aws_lambda_permission.notify_slack_from_sns will be created + resource "aws_lambda_permission" "notify_slack_from_sns" { + action = "lambda:InvokeFunction" + function_name = "notify-demo-notify-slack" + id = (known after apply) + principal = "sns.amazonaws.com" + region = "ap-northeast-1" + source_arn = (known after apply) + statement_id = "AllowExecutionFromSNS" + statement_id_prefix = (known after apply) } # module.notify.aws_scheduler_schedule.fetch_status will be created + resource "aws_scheduler_schedule" "fetch_status" { + action_after_completion = (known after apply) + arn = (known after apply) + group_name = (known after apply) + id = (known after apply) + name = "notify-demo-fetch-status" + name_prefix = (known after apply) + region = "ap-northeast-1" + schedule_expression = "rate(1 minute)" + schedule_expression_timezone = "UTC" + state = "ENABLED" + flexible_time_window { + mode = "OFF" } + target { + arn = (known after apply) + input = jsonencode( { + source = "scheduler" } ) + role_arn = (known after apply) } } # module.notify.aws_sns_topic.result will be created + resource "aws_sns_topic" "result" { + arn = (known after apply) + beginning_archive_time = (known after apply) + content_based_deduplication = false + fifo_throughput_scope = (known after apply) + fifo_topic = false + id = (known after apply) + maximum_message_size = (known after apply) + name = "notify-demo-result" + name_prefix = (known after apply) + owner = (known after apply) + policy = (known after apply) + region = "ap-northeast-1" + signature_version = (known after apply) + tags_all = (known after apply) + tracing_config = (known after apply) } # module.notify.aws_sns_topic_subscription.result_to_notify_slack will be created + resource "aws_sns_topic_subscription" "result_to_notify_slack" { + arn = (known after apply) + confirmation_timeout_in_minutes = 1 + confirmation_was_authenticated = (known after apply) + endpoint = (known after apply) + endpoint_auto_confirms = false + filter_policy_scope = (known after apply) + id = (known after apply) + owner_id = (known after apply) + pending_confirmation = (known after apply) + protocol = "lambda" + raw_message_delivery = false + region = "ap-northeast-1" + topic_arn = (known after apply) } Plan: 16 to add, 0 to change, 0 to destroy. Changes to Outputs: + fetch_status_function_name = "notify-demo-fetch-status" Do you want to perform these actions? Terraform will perform the actions described above. Only 'yes' will be accepted to approve. Enter a value: yes module.notify.aws_cloudwatch_log_group.notify_slack: Creating... module.notify.aws_cloudwatch_log_group.fetch_status: Creating... module.notify.aws_iam_role.fetch_status: Creating... module.notify.aws_iam_role.scheduler: Creating... module.notify.aws_sns_topic.result: Creating... module.notify.aws_iam_role.notify_slack: Creating... module.notify.aws_iam_role.fetch_status: Creation complete after 0s [id=notify-demo-fetch-status-role] module.notify.aws_iam_role.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role] module.notify.aws_iam_role.notify_slack: Creation complete after 0s [id=notify-demo-notify-slack-role] module.notify.aws_cloudwatch_log_group.fetch_status: Creation complete after 0s [id=/aws/lambda/notify-demo-fetch-status] module.notify.aws_cloudwatch_log_group.notify_slack: Creation complete after 0s [id=/aws/lambda/notify-demo-notify-slack] module.notify.aws_sns_topic.result: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:000000000000:notify-demo-result] module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creating... module.notify.data.aws_iam_policy_document.fetch_status: Reading... module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creating... module.notify.aws_iam_role_policy.notify_slack: Creating... module.notify.data.aws_iam_policy_document.fetch_status: Read complete after 0s [id=193309822] module.notify.aws_lambda_function.notify_slack: Creating... module.notify.aws_lambda_function.fetch_status: Creating... module.notify.aws_iam_role_policy_attachment.fetch_status_logs: Creation complete after 0s [id=notify-demo-fetch-status-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole] module.notify.aws_iam_role_policy.notify_slack: Creation complete after 0s [id=notify-demo-notify-slack-role:terraform-0c334b43e9f77ddf68c66755f5] module.notify.aws_iam_role_policy_attachment.notify_slack_logs: Creation complete after 0s [id=notify-demo-notify-slack-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole] module.notify.aws_iam_role_policy.fetch_status: Creating... module.notify.aws_iam_role_policy.fetch_status: Creation complete after 0s [id=notify-demo-fetch-status-role:terraform-5e557969d0acd65274a217f306] module.notify.aws_lambda_function.notify_slack: Creation complete after 5s [id=notify-demo-notify-slack] module.notify.aws_lambda_permission.notify_slack_from_sns: Creating... module.notify.aws_lambda_permission.notify_slack_from_sns: Creation complete after 0s [id=AllowExecutionFromSNS] module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creating... module.notify.aws_sns_topic_subscription.result_to_notify_slack: Creation complete after 0s [id=arn:aws:sns:ap-northeast-1:000000000000:notify-demo-result:8876cdbc-24f6-49f7-b09e-14fb507ff76e] module.notify.aws_lambda_function.fetch_status: Still creating... [00m10s elapsed] module.notify.aws_lambda_function.fetch_status: Creation complete after 10s [id=notify-demo-fetch-status] module.notify.data.aws_iam_policy_document.scheduler: Reading... module.notify.data.aws_iam_policy_document.scheduler: Read complete after 0s [id=1455079044] module.notify.aws_scheduler_schedule.fetch_status: Creating... module.notify.aws_iam_role_policy.scheduler: Creating... module.notify.aws_scheduler_schedule.fetch_status: Creation complete after 0s [id=default/notify-demo-fetch-status] module.notify.aws_iam_role_policy.scheduler: Creation complete after 0s [id=notify-demo-scheduler-role:terraform-db943e5a1f8c21b3a1a29f7c69] Apply complete! Resources: 16 added, 0 changed, 0 destroyed. Outputs: fetch_status_function_name = "notify-demo-fetch-status" Two functions appear on the StackPort Lambda screen. !StackPort Lambda function list StackPort does not show schedules, so I check them with the CLI. $ aws --endpoint-url=http://localhost:4566 --region ap-northeast-1 \ > scheduler list-schedules { "Schedules": [ { "Arn": "arn:aws:scheduler:ap-northeast-1:000000000000:schedule/default/notify-demo-fetch-status", "CreationDate": "2026-10-12T09:56:36.343454+09:00", "GroupName": "default", "LastModificationDate": "2026-10-12T09:56:36.343454+09:00", "Name": "notify-demo-fetch-status", "State": "ENABLED", "Target": { "Arn": "arn:aws:lambda:ap-northeast-1:000000000000:function:notify-demo-fetch-status" } } ] } Step 5: Verify the Behavior After a short wait, API status: ok is posted to #notify-demo-local. !Notification posted to Slack from the local environment You can check the Lambda execution logs on the StackPort CloudWatch Logs screen. First, the log group list shows /aws/lambda/notify-demo-notify-slack and /aws/lambda/notify-demo-fetch-status. !StackPort log group list Opening /aws/lambda/notify-demo-notify-slack shows a list of log streams, one per execution. !StackPort log stream list Opening a log stream shows START, END, and REPORT, plus what the Lambda function printed: the same API status: ok (…) that went to Slack. !StackPort log events Deploying to Real AWS and Verifying the Same Behavior I deploy the pipeline I verified locally to a real AWS account and check that it behaves the same way. The Lambda code and the Terraform module are shared with the local environment. Only the environment directory (environments/aws) and the values registered in SSM change. !Real AWS architecture The thick red borders show what differs from the local environment. An API Gateway created in advance stands in for the external API, and Slack notifications go to the AWS channel (#notify-demo-aws). Step 1: Register the SSM Parameters The API key was generated by the API Gateway. I retrieve its value and register it in SSM as a SecureString, together with the Incoming Webhook URL of the AWS channel. Apart from retrieving the API key, the script is the same as the local one, so I omit its contents. API_KEY_ID=<API key ID> SLACK_WEBHOOK_URL=<Webhook URL of the AWS channel> ./scripts/put-parameters-aws.sh This is the Parameter Store in the AWS console after registration. !AWS SSM parameter list Opening a parameter shows its SecureString value as ********. Turn on "Show decrypted value" to see the value. !AWS SSM parameter details Step 2: Deploy terraform/environments/aws/providers.tf has no MiniStack-specific settings. provider "aws" { region = "ap-northeast-1" } The module call (terraform/environments/aws/main.tf) is the same as the local one. module "notify" { source = "../../modules/notify" region = "ap-northeast-1" api_base_url = var.api_base_url } Only the external API URL changes, in terraform/environments/aws/terraform.tfvars. This file is not managed in Git either, so create it by copying the template (terraform.tfvars.example) and rewrite it with the URL of the API Gateway you created in advance. api_base_url = "https://<API ID>.execute-api.ap-northeast-1.amazonaws.com/prod" Apply it. cd terraform/environments/aws cp terraform.tfvars.example terraform.tfvars # Rewrite <API ID> in terraform.tfvars terraform init terraform apply For comparison with the apply against MiniStack, here is the result. The account ID is masked. $ terraform apply module.notify.data.archive_file.fetch_status: Reading... module.notify.data.archive_file.notify_slack: Reading... module.notify.data.archive_file.notify_slack: Read complete after 0s [id=68fb7c506afbeeab169fb65494efc63dd8acba90] module.notify.data.archive_file.fetch_status: Read complete after 0s [id=013b111cb8285884b651e501fd6f84ad373f787a] module.notify.data.aws_iam_policy_document.lambda_assume: Reading... module.notify.data.aws_caller_identity.current: Reading... module.notify.data.aws_iam_policy_document.lambda_assume: Read complete after 0s [id=2690255455] module.notify.data.aws_caller_identity.current: Read complete after 0s [id=123456789012] module.notify.data.aws_iam_policy_document.scheduler_assume: Reading... module.notify.data.aws_iam_policy_document.notify_slack: Reading... module.notify.data.aws_iam_policy_document.notify_slack: Read complete after 0s [id=3754217979] module.notify.data.aws_iam_policy_document.scheduler_assume: Read complete after 0s [id=991516426] Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: - create <= read (data resources) Terraform will perform the following actions: module.notify.data.aws_iam_policy_document.fetch_status will be read during apply (config refers to values not yet known) <= data "aws_iam_policy_document" "fetch_status" { - id = (known after apply) - json = (known after apply) - minified_json = (known after apply) - statement { - actions = [ - "ssm:GetParameter", ] - resources = [ - "arn:aws:ssm:ap-northeast-1:123456789012:parameter/notify-demo/api-key", ] } - statement { - actions = [ - "sns:Publish", ] - resources = [ - (known after apply), ] } } module.notify.data.aws_iam_policy_document.scheduler will be read during apply (config refers to values not yet known) <= data "aws_iam_policy_document" "scheduler" { - id = (known after apply) - json = (known after apply) - minified_json = (known after apply) - statement { - actions = [ - "lambda:InvokeFunction", ] - resources = [ - (known after apply), ] } } module.notify.aws_cloudwatch_log_group.fetch_status will be created - resource "aws_cloudwatch_log_group" "fetch_status" { - arn = (known after apply) - deletion_protection_enabled = (known after apply) - id = (known after apply) - log_group_class = (known after apply) - name = "/aws/lambda/notify-demo-fetch-status" - name_prefix = (known after apply) - region = "ap-northeast-1" - retention_in_days = 7 - skip_destroy = false - tags_all = (known after apply) } module.notify.aws_cloudwatch_log_group.notify_slack will be created - resource "aws_cloudwatch_log_group" "notify_slack" { - arn = (known after apply) - deletion_protection_enabled = (known after apply) - id = (known after apply) - log_group_class = (known after apply) - name = "/aws/lambda/notify-demo-notify-slack" - name_prefix = (known after apply) - region = "ap-northeast-1" - retention_in_days = 7 - skip_destroy = false - tags_all = (known after apply) } module.notify.aws_iam_role.fetch_status will be created - resource "aws_iam_role" "fetch_status" { - arn = (known after apply) - assume_role_policy = jsonencode( { - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" } }, ] - Version = "2012-10-17" } ) - create_date = (known after apply) - force_detach_policies = false - id = (known after apply) - managed_policy_arns = (known after apply) - max_session_duration = 3600 - name = "notify-demo-fetch-status-role" - name_prefix = (known after apply) - path = "/" - tags_all = (known after apply) - unique_id = (known after apply) - inline_policy (known after apply) } module.notify.aws_iam_role.notify_slack will be created - resource "aws_iam_role" "notify_slack" { - arn = (known after apply) - assume_role_policy = jsonencode( { - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "lambda.amazonaws.com" } }, ] - Version = "2012-10-17" } ) - create_date = (known after apply) - force_detach_policies = false - id = (known after apply) - managed_policy_arns = (known after apply) - max_session_duration = 3600 - name = "notify-demo-notify-slack-role" - name_prefix = (known after apply) - path = "/" - tags_all = (known after apply) - unique_id = (known after apply) - inline_policy (known after apply) } module.notify.aws_iam_role.scheduler will be created - resource "aws_iam_role" "scheduler" { - arn = (known after apply) - assume_role_policy = jsonencode( { - Statement = [ - { - Action = "sts:AssumeRole" - Condition = { - StringEquals = { - "aws:SourceAccount" = "123456789012" } } - Effect = "Allow" - Principal = { - Service = "scheduler.amazonaws.com" } }, ] - Version = "2012-10-17" } ) - create_date = (known after apply) - force_detach_policies = false - id = (known after apply) - managed_policy_arns = (known after apply) - max_session_duration = 3600 - name = "notify-demo-scheduler-role" - name_prefix = (known after apply) - path = "/" - tags_all = (known after apply) - unique_id = (known after apply) - inline_policy (known after apply) } module.notify.aws_iam_role_policy.fetch_status will be created - resource "aws_iam_role_policy" "fetch_status" { - id = (known after apply) - name = (known after apply) - name_prefix = (known after apply) - policy = (known after apply) - role = (known after apply) } module.notify.aws_iam_role_policy.notify_slack will be created - resource "aws_iam_role_policy" "notify_slack" { - id = (known after apply) - name = (known after apply) - name_prefix = (known after apply) - policy = jsonencode( { - Statement = [ - { - Action = "ssm:GetParameter" - Effect = "Allow" - Resource = "arn:aws:ssm:ap-northeast-1:123456789012:parameter/notify-demo/slack-webhook-url" }, ] - Version = "2012-10-17" } ) - role = (known after apply) } module.notify.aws_iam_role_policy.scheduler will be created - resource "aws_iam_role_policy" "scheduler" { - id = (known after apply) - name = (known after apply) - name_prefix = (known after apply) - policy = (known after apply) - role = (known after apply) } module.notify.aws_iam_role_policy_attachment.fetch_status_logs will be created - resource "aws_iam_role_policy_attachment" "fetch_status_logs" { - id = (known after apply) - policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" - role = "notify-demo-fetch-status-role" } module.notify.aws_iam_role_policy_attachment.notify_slack_logs will be created - resource "aws_iam_role_policy_attachment" "notify_slack_logs" { - id = (known after apply) - policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" - role = "notify-demo-notify-slack-role" } module.notify.aws_lambda_function.fetch_status will be created - resource "aws_lambda_function" "fetch_status" { - architectures = (known after apply) - arn = (known after apply) - code_sha256 = (known after apply) - filename = "./build/fetch_status.zip" - function_name = "notify-demo-fetch-status" - handler = "app.handler" - id = (known after apply) - invoke_arn = (known after apply) - last_modified = (known after apply) - memory_size = 128 - package_type = "Zip" - publish = false - qualified_arn = (known after apply) - qualified_invoke_arn = (known after apply) - region = "ap-northeast-1" - reserved_concurrent_executions = -1 - response_streaming_invoke_arn = (known after apply) - role = (known after apply) - runtime = "python3.14" - signing_job_arn = (known after apply) - signing_profile_version_arn = (known after apply) - skip_destroy = false - source_code_hash = "So5oKi3b/bEDekVIzzKxoKwo1aO2n/JdG+Ioj2nhtVw=" - source_code_size = (known after apply) - tags_all = (known after apply)
Parity Suite 3.6 and a month of emulator fixes When I wrote up [3.0.0](/articles/parity-suite-300-release-notes) in August, Floci was a C on the board with 209 failing tests. By 3rd September the suite had grown to 1,251 tests and it was a D with 337. This morning's run has it failing one. That's the real news in this release, and none of it is mine. The suite barely grew. The emulators got better. The board The suite reached 1,251 tests on 3rd September, so that's the fairest place to measure from. It's at 1,266 now. | | 3rd September | 9th October | |---|---|---| | Floci | D, 337 failing | A, 1 failing | | LocalStack | B, 101 failing | A, 31 failing | | MiniStack | C, 220 failing | B, 84 failing | All three now pass every Tier 1 test, the core of DynamoDB that most applications rely on. Five weeks ago Floci failed 50 of those, MiniStack 28 and LocalStack 14. Floci didn't get there by doing less, either. Its coverage, the share of the suite it attempts rather than skips, went up from 95.1% to 99.4% over the same stretch. Its one remaining failure is an edge case. A transaction carries a value nested deeper than DynamoDB's 32-level limit, but only in a condition, so it never gets written. Most regions don't check its depth and go on to evaluate the condition. eu-west-2 and the two other regions that now validate transactions up front (more on that below) refuse the whole request. Floci does neither: it cancels the transaction. Most of LocalStack's 31 are PartiQL (21 of them), with the rest in exact error wording and limits. MiniStack's 84 are nearly all Tier 3, mostly exact error messages and limits. It's good to see the board being used for what I built it for. Floci's tests and pull requests cite it by name, and MiniStack's maintainers point contributors to it when the question of how close to real DynamoDB they are comes up. Thanks to both teams for that. AWS moved in the middle of a run 3.6.0 was tagged on 8th October and never published. While it was being measured, eu-west-2 changed how it validates transactions, so five tests failed against real DynamoDB and no board came out. It's part of the same rollout I [noticed back in June](/articles/dynamodb-changed-its-validation-errors), and it still hasn't finished. eu-west-2, eu-north-1 and ap-northeast-2 now check a transaction's request before running it. The other 30 regions don't yet, and a few of the changes are more than wording: the same request can succeed in one region and fail in another. For example, take an item that's already there, and a single SET that brings it up to exactly DynamoDB's 400KB limit (409,600 bytes), sent as an Update inside TransactWriteItems: // bigValue brings the item to 409,600 bytes await ddb.send(new TransactWriteItemsCommand({ TransactItems: [{ Update: { TableName: 'orders', Key: { pk: { S: 'Y' } }, UpdateExpression: 'SET p = :p', ExpressionAttributeValues: { ':p': bigValue }, }, }], })) In us-east-1 that succeeds and the item is stored at 409,600 bytes. In eu-west-2, since 8th October, it fails with ValidationException: Item size to update has exceeded the maximum allowed size. If your tests run against one region and production sits in another, the two can disagree on this request. It's another reason [you can't pin DynamoDB to one region](/articles/you-cant-pin-dynamodb-to-one-region). 3.6.1 is the fix. Those five tests accept either answer until the regions settle, and no engine loses a test over it. Pinning to the old answer would fail real DynamoDB in eu-west-2, which is the region the suite measures against, and pinning to the new one would fail every engine that matches the 30 regions still giving the old answer. Both answers are what DynamoDB actually does today, so both count. Smaller changes The board now sorts by grade, best first. It used to sort on divergence alone, which could put a target with a worse grade above a better one. No grade or figure changed. The suite grew by fifteen tests across 3.5.0 and 3.6, fourteen of them from exoego, covering size and nesting limits on batch and transaction writes. Coming next The suite's going to grow again soon. I've found some gaps in what it covers, and there are around 160 new tests waiting to go in, all checked against real DynamoDB first. I'll probably merge them early next week, and I'd expect most engines' figures to move when they do. The full notes are in the 3.6.1 release, the suite is at paritysuite/dynamodb-conformance, and the board is at paritysuite.org. If something looks wrong, an issue is welcome.
Ministack corre 60+ servicios de AWS en local desde un solo puerto. Alternativa gratuita a LocalStack con soporte real para RDS y Bedrock. ministackorg/ministack
Runs 60+ AWS services locally on a single port as a free alternative to LocalStack with real infrastructure support for RDS and Bedrock. ministackorg/ministack #CyberSecurity #InfoSec #e0xsecops #TechLeadershi t.co