DEV Community

SGWebFreelancer
SGWebFreelancer

Posted on Edited on

Our .NET App Crashed on 11.11. Here's the Queue Pattern That Fixed It

1. The Monday brief

I joined Lion City Logistics, a delivery startup in Singapore. Three weeks earlier, their tracking app (ParcelTrack) went down for hours during the 11.11 sale.

On my first Monday, the CTO sent me this message:

"Move ParcelTrack to AWS Singapore. It must handle traffic spikes. Keep secrets out of the code. Tell us when something breaks, before customers notice. Deploy automatically when we merge to main on Bitbucket. We need a UAT environment and a production environment."

First, I wanted to know what actually broke. If I move a broken design to the cloud, I just get a broken design that costs more money.


2. What went wrong

I read the old code and the incident notes. I found five problems.

# What I found Why it hurt on 11.11
1 The API made the PDF invoice and sent the email inside the web request Each request took about 8 seconds. Many requests at once used up all the threads. Everything timed out.
2 If the email server had a problem, the whole request failed Customers pressed retry. This doubled the load.
3 One server, no load balancer One crash meant the whole site was down.
4 The database password was in appsettings.json in the repo Anyone with repo access could reach production data.
5 To deploy, you remote-desktop into the server and copy files Slow. Easy to make mistakes. No way to roll back.

Problems 1 and 2 caused the crash. The other three made it worse and made recovery hard.


3. The one rule I decided to follow

I wrote one sentence on a sticky note and put it on my monitor:

The API only does fast things. Anything slow, heavy, or likely to fail goes on a queue. A separate worker does it.


4. Why I ran the app on my laptop first

I could have started clicking in AWS right away. But I have learned this the hard way: if you debug code problems and cloud problems at the same time, you cannot tell which one is failing.

So first I made the new design work on my laptop. Then I moved it to AWS, knowing the code was fine.

To do this, I run six containers. Some were new to me, so here is what each one is.

Container What it is What does this job in AWS
db A PostgreSQL database RDS
api The web API (ASP.NET Core) ECS or EKS
worker Does background jobs ECS or EKS
web The web page users see S3 + CloudFront
localstack A fake AWS on my laptop SQS and S3
mailpit A fake email server with an inbox page SES

The trick that makes this work

I want the same code on my laptop and in AWS. So I use one setting to choose where my code talks to. I show it in Stage 3.


5. Get the code

The folder looks like this:

parceltrack-aws-tutorial/
├── src/
│   ├── ParcelTrack.Core/      shared model, database, email, AWS setup
│   ├── ParcelTrack.Api/       the web API (fast things only)
│   ├── ParcelTrack.Worker/    background jobs (slow things)
│   └── ParcelTrack.Web/       the web page (Blazor)
├── scripts/                   LocalStack setup + a quick test
├── docker-compose.yml         starts all six containers
└── README.md
Enter fullscreen mode Exit fullscreen mode

To run everything:

docker compose up --build
Enter fullscreen mode Exit fullscreen mode

The first build takes a few minutes. Then open:

Address What you see
http://localhost:8081 The ParcelTrack page
http://localhost:8080/health The word Healthy
http://localhost:8025 Mailpit (the fake inbox)

At the end of each stage, I can run something and see it work. If a stage breaks, I know which new piece caused it.


6. Building it in five stages

Stage 1: The database and the API

Goal: create a parcel and list parcels. Nothing else yet.

Files: Core/Parcel.cs, Core/ParcelDb.cs, Api/Program.cs, Api/Dockerfile

I put the data model in a separate Core project. Both the API and the worker need to read the same tables. If I define a parcel in one place, the two cannot disagree.

This C# class becomes a database table:

public class Parcel
{
    public Guid Id { get; set; }
    public string TrackingNumber { get; set; } = "";
    public string Recipient { get; set; } = "";
    public string CustomerEmail { get; set; } = "";
    public string Status { get; set; } = "Created";   // Created -> InTransit -> Delivered
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

This is how Entity Framework talks to the database. DbSet<Parcel> means "a table of parcels":

public class ParcelDb(DbContextOptions<ParcelDb> options) : DbContext(options)
{
    public DbSet<Parcel> Parcels => Set<Parcel>();
    public DbSet<Invoice> Invoices => Set<Invoice>();
}
Enter fullscreen mode Exit fullscreen mode

In the API's Program.cs, the first lines connect to the database:

builder.Services.AddDbContext<ParcelDb>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
Enter fullscreen mode Exit fullscreen mode

Look at where the connection string comes from. It is not in the code. It comes from configuration. This is my first step to fix problem 4 (the password in the repo).

Then there are the endpoints. The important ones:

app.MapHealthChecks("/health");      // "Am I alive?" The load balancer will ask this later.

app.MapGet("/api/parcels", ...);     // list parcels
app.MapPost("/api/parcels", ...);    // create a parcel
Enter fullscreen mode Exit fullscreen mode

The Dockerfile uses two stages. The first stage has the full .NET SDK and builds the app. The second stage copies only the result onto a small image. The final image is much smaller and has no compiler in it.

I copy the .csproj files and run restore before I copy the source code. Docker saves that step in its cache, so later builds take seconds.

ASP.NET Core and environment variables: in docker-compose.yml you see ConnectionStrings__Default. The double underscore __ means a nested setting. It becomes ConnectionStrings:Default. ECS and EKS will use the same method later.

Check it works:

curl -X POST localhost:8080/api/parcels \
  -H "Content-Type: application/json" \
  -d '{"recipient":"Tan Wei Ming","email":"tan@example.com"}'

curl localhost:8080/api/parcels
Enter fullscreen mode Exit fullscreen mode

You should see your parcel with a tracking number like LCL482910.


Stage 2: The web page (what the user sees)

Goal: a page that talks to the API.

Files: Web/Program.cs, Web/Pages/Home.razor, Web/wwwroot/appsettings.json

I used Blazor WebAssembly. It is C#, which my team already knows. It also builds into plain static files. Static files need no server. This will be useful when I use S3 and CloudFront later.

Three things matter on this page.

1. The API address is a setting, not hard-coded. It lives in wwwroot/appsettings.json:

{ "ApiUrl": "http://localhost:8080" }
Enter fullscreen mode Exit fullscreen mode

Later, I can point the same build at UAT or production by changing this one file.

2. The page refreshes by itself every 3 seconds. So you can watch the status change:

timer = new Timer(async _ =>
{
    await Load();
    await InvokeAsync(StateHasChanged);
}, null, 3000, 3000);
Enter fullscreen mode Exit fullscreen mode

3. The button that creates a parcel:

var res = await Http.PostAsJsonAsync("api/parcels", new { Recipient = recipient, Email = email });
Enter fullscreen mode Exit fullscreen mode

My first problem here: CORS. My browser refused to let the page on localhost:8081 call the API on localhost:8080. This is a browser safety rule. The API must specify which websites are allowed. I fixed it in the API:

builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
    p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod()));
// ...
app.UseCors();
Enter fullscreen mode Exit fullscreen mode

origins comes from a setting (AllowedOrigins). UAT and production will have different website addresses, so I did not want to hard-code it.

Check it works: open http://localhost:8081, type a name and email, and click Create parcel. A new row appears.


Stage 3: The queue

Goal: when someone asks for an invoice, the API puts a ticket on a queue and replies at once.

Files: scripts/localstack-init.sh, Core/AwsExtensions.cs, the /invoice endpoint in Api/Program.cs

In docker-compose.yml, LocalStack runs a script when it is ready. The script creates my queues and buckets:

awslocal s3 mb s3://parceltrack-invoices-local
awslocal sqs create-queue --queue-name parceltrack-invoices-dlq
awslocal sqs create-queue --queue-name parceltrack-invoices \
  --attributes '{"RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:parceltrack-invoices-dlq\",\"maxReceiveCount\":\"3\"}","VisibilityTimeout":"60"}'
Enter fullscreen mode Exit fullscreen mode

To write this script, I had to learn three SQS ideas:

Idea Simple meaning
Dead-letter queue (DLQ) A second queue. Messages that keep failing go here, so I can look at them. They do not retry forever.
maxReceiveCount = 3 If a message was picked up 3 times and never finished, SQS moves it to the DLQ.
Visibility timeout = 60s When a worker takes a message, other workers cannot see it for 60 seconds. If the worker finishes, it deletes the message. If it crashes, the message comes back after 60 seconds. This number must be longer than your longest job.

Now the endpoint. This is the heart of the fix:

app.MapPost("/api/parcels/{id:guid}/invoice", async (
    Guid id, CreateInvoice req, ParcelDb db, IAmazonSQS sqs, IConfiguration cfg) =>
{
    // One invoice per parcel. A customer who clicks twice gets the SAME invoice back.
    var invoice = await db.Invoices.FirstOrDefaultAsync(i => i.ParcelId == id);

    if (invoice is null)
    {
        invoice = new Invoice { /* ... Status = "Pending" ... */ };
        db.Invoices.Add(invoice);
        try { await db.SaveChangesAsync(); }
        catch (DbUpdateException)
        {
            // Two requests at the same moment: the unique index lets only one in
            db.ChangeTracker.Clear();
            invoice = await db.Invoices.FirstAsync(i => i.ParcelId == id);
        }
    }

    // Put a ticket on the queue. Only the ID, not all the data.
    if (invoice.Status == "Pending")
        await sqs.SendMessageAsync(cfg["InvoiceQueueUrl"],
            JsonSerializer.Serialize(new InvoiceRequested(invoice.Id)));

    // Reply right away. 202 Accepted means "I got it, but it is not finished."
    return Results.Accepted($"/api/invoices/{invoice.Id}", new { invoice.Id, invoice.Status });
});
Enter fullscreen mode Exit fullscreen mode

Notice the first lines. On 11.11, customers pressed retry when the site was slow. Without these lines, each retry would make a new invoice and a new email. So the API itself must be safe against duplicates. The rule is enforced by the database, not by my code alone:

b.Entity<Invoice>().HasIndex(i => i.ParcelId).IsUnique();   // one invoice per parcel
Enter fullscreen mode Exit fullscreen mode

(I added this after a reader asked about duplicates. See the section "Duplicate emails, properly" in Stage 4.)

I only put the ID in the message. The database stays the one true source of data. Messages stay small.

Now the one setting I promised. In Core/AwsExtensions.cs, all AWS clients are created in one place:

var url = cfg["Aws:ServiceUrl"];     // set on my laptop, empty in AWS

s.AddSingleton<IAmazonSQS>(_ => !string.IsNullOrEmpty(url)
    ? new AmazonSQSClient(new AmazonSQSConfig { ServiceURL = url })   // talk to LocalStack
    : new AmazonSQSClient());                                          // talk to real AWS
Enter fullscreen mode Exit fullscreen mode
  • On my laptop, Aws:ServiceUrl points to LocalStack.
  • In AWS, I leave it empty. The SDK then uses the real service and the IAM role of the running container. The S3 client does the same, with one extra setting: ForcePathStyle = true for LocalStack. Real S3 uses addresses like bucket.s3.amazonaws.com. LocalStack uses localhost:4566/bucket.

Check it works:

./scripts/smoke-test.sh
Enter fullscreen mode Exit fullscreen mode

The invoice request should return HTTP 202 in a few milliseconds.


Stage 4: The worker

Goal: read the tickets, make the PDF, store it in S3, and email the link.

Files: Worker/InvoiceWorker.cs, Worker/InvoicePdf.cs, Core/Email.cs, Worker/ParcelProgressWorker.cs

A .NET Worker Service is a console app that runs background tasks. The template gives you a Worker.cs that only prints the time. I replaced it with InvoiceWorker. It has two parts.

Part 1: the loop that takes tickets

var res = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
    QueueUrl = queueUrl,
    MaxNumberOfMessages = 5,
    WaitTimeSeconds = 20        // long polling
}, ct);

foreach (var msg in res.Messages)
{
    try
    {
        var job = JsonSerializer.Deserialize<InvoiceRequested>(msg.Body)!;
        var finished = await Process(job.InvoiceId, ct);
        if (finished)
            await sqs.DeleteMessageAsync(queueUrl, msg.ReceiptHandle, ct);   // done: remove the ticket
    }
    catch (Exception ex)
    {
        log.LogError(ex, "Invoice job failed");   // do NOT delete: it will come back and retry
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  • Long polling (WaitTimeSeconds = 20): SQS waits up to 20 seconds for a message before it answers "nothing". This means fewer empty calls and a smaller bill.
  • I delete the message only when the job is really finished. This one rule is the whole retry system. No delete means the message comes back. (Process returns false when another worker is busy with the same invoice. Then I leave the message alone.)

Part 2: the real work

async Task Process(Guid invoiceId, CancellationToken ct)
{
    var inv = await db.Invoices.FindAsync([invoiceId], ct);

    if (inv.Status == "Sent") return;          // see "idempotency" below

    // 1. Make the PDF and store it in S3
    var pdf = InvoicePdf.Render(inv);
    // ... upload to S3, set Status = "Generated"

    // 2. Make a link that works for 24 hours, and email it
    var link = s3.GetPreSignedURL(/* bucket, key, expires in 24h */);
    await email.SendAsync(inv.CustomerEmail, "Your invoice", $"Download: {link}", ct);

    // 3. Mark as Sent
}
Enter fullscreen mode Exit fullscreen mode
  • Pre-signed URL. A temporary link that lets someone download one private file for a short time. The bucket stays private. The customer still gets a working link.

Duplicate emails, properly (a reader's question)

A reader pointed out a hole in my first version, and they were right. SQS standard queues deliver messages at least once. That means the same message can arrive twice. My first version only checked Status == "Sent" before sending, and saved Sent after. Look at the gap:

send email ──► (crash here) ──► save "Sent"
Enter fullscreen mode Exit fullscreen mode

If the worker dies in that gap, the email is out but not recorded. The message comes back and the customer gets a second email. Two workers can also both pass the check at the same moment. And on the API side, a customer retry made a second invoice. So there are three ways to get duplicates:

How a duplicate happens My fix
Customer retries, API makes a second invoice One invoice per parcel, enforced by a unique index. The retry returns the same invoice.
Same message delivered twice, or two workers at once Claim before send: one atomic database update. Only one worker wins.
Worker crashes after the email, before saving Sent Cannot be fully removed. See below.

Why "claim before send" and not just "write a key first"? If I record "sending" and then crash before the email goes out, the retry would see the record and skip it. The email would never be sent. That trades a duplicate for a lost email, which is worse. So the claim is a short lease (45 seconds), not a permanent mark:

  • A worker claims the email and sends it.
  • If it dies, the claim expires after 45 seconds. The message returns after 60 seconds (the queue's visibility timeout), and another worker can claim it.
  • The lease must be shorter than the visibility timeout. If not, the returning message would find the claim still active and give up. The honest limit. Email is an outside system. I cannot make "send the email" and "save to my database" happen as one all-or-nothing step. So if a worker dies in that tiny gap, a duplicate is still possible. I cannot remove it. I can only make it rare, and make it harmless: the second email contains a link to the same invoice PDF, so the customer sees the same file twice and nothing is charged or created twice.

What about a FIFO queue? SQS FIFO queues can drop duplicate sends using a MessageDeduplicationId (for example the parcel ID). That is useful, but it only remembers for 5 minutes, and it does not help when a message is delivered twice after a worker crash. A database rule that never expires (the unique index) is stronger, so I use the database. FIFO is a good extra layer if you need strict ordering too.

Swappable email. My worker calls email.SendAsync(...). It never says which email system. There are two classes behind one interface, and a setting picks one:

s.AddSingleton<IEmailSender>(sp => cfg["Email:Provider"] == "ses"
    ? ActivatorUtilities.CreateInstance<SesEmailSender>(sp)      // AWS (Tutorial 3)
    : ActivatorUtilities.CreateInstance<SmtpEmailSender>(sp));   // Mailpit (my laptop)
Enter fullscreen mode Exit fullscreen mode

This is the same idea as LocalStack: the worker code does not change between my laptop and AWS.

The simulator. There are no real drivers in my demo. So I added a second small worker, ParcelProgressWorker. It moves parcels from Created to InTransit to Delivered every few seconds. This makes the page look alive.

It is not part of the real design, so I put it behind a switch:

if (builder.Configuration.GetValue<bool>("Simulator:Enabled"))
    builder.Services.AddHostedService<ParcelProgressWorker>();
Enter fullscreen mode Exit fullscreen mode

I turn it on locally and in UAT. I leave it off in production, where real driver updates will come in.

One more fix I made. At first, the worker crashed if LocalStack was not ready yet. Now the worker catches the error, waits 5 seconds, and tries again.


Stage 5: Put it all together in Docker Compose

Six services in one file is a lot to read. So I wrote the settings that the API and worker share just once:

x-app-env: &app-env                         # a reusable block
  ConnectionStrings__Default: "Host=db;Database=parceltrack;Username=postgres;Password=localdev"
  Aws__ServiceUrl: http://localstack:4566   # "talk to the fake AWS"
  AWS_ACCESS_KEY_ID: test                   # LocalStack accepts any key
  AWS_SECRET_ACCESS_KEY: test
  AWS_REGION: us-east-1
  InvoiceQueueUrl: http://localstack:4566/000000000000/parceltrack-invoices
  InvoiceBucket: parceltrack-invoices-local
  Email__Provider: smtp                     # "use Mailpit, not SES"
  Smtp__Host: mailpit
  Smtp__Port: "1025"
Enter fullscreen mode Exit fullscreen mode

Each service then reuses the block:

  api:
    environment: *app-env
  worker:
    environment:
      <<: *app-env                          # everything above...
      Simulator__Enabled: "true"            # ...plus one more setting
Enter fullscreen mode Exit fullscreen mode

One thing that confused me: inside Docker Compose, containers find each other by service name. So the API connects to Host=db and http://localstack:4566. It never uses localhost. Inside a container, localhost means the container itself.


7. The 11.11 test

Now I tested the exact thing that broke before.

First the normal flow:

  1. Create a parcel on the page.
  2. Click Email invoice.
  3. Watch the badge go Pending then Generated then Sent.
  4. Open http://localhost:8025. The email is there. Now the disaster:
docker compose stop mailpit        # the email server "dies"
Enter fullscreen mode Exit fullscreen mode

I created and invoiced 20 parcels. The page stayed fast, because the API only writes tickets. The worker printed errors. The tickets waited in the queue.

Then:

docker compose start mailpit       # the email server comes back
Enter fullscreen mode Exit fullscreen mode

In about a minute, all 20 emails arrived.

In the old system, this exact situation took the whole site down. This is when I felt ready to show my boss.


8. From the brief to an AWS shopping list

Now that the code works, I can choose AWS services. For each need, I asked: what do I need, and which service gives it to me?

What the CTO wants What I need AWS service Why this one
Handle spikes Run many copies, add more automatically ECS Fargate and EKS No servers to patch. The CTO mentioned EKS, so I will build both and compare.
No single point of failure Share traffic between copies and data centres Application Load Balancer Health checks remove sick copies.
Slow work off the request path A buffer between API and worker SQS (with a DLQ) Cheap, simple, retries built in.
Store invoices and delivery photos Cheap, safe file storage S3 Lifecycle rules, pre-signed URLs.
Email invoices Send email from code SES Cheapest on AWS.
Store data Managed Postgres with backups RDS PostgreSQL AWS does patching, backups, failover.
Secrets out of code A safe place for passwords Secrets Manager Controlled by IAM. Can rotate.
Know when it breaks Logs, graphs, alarms, alerts CloudWatch and SNS Built in to everything above.
A protected front door Rate limits, routing, TLS API Gateway Protects us from the next 11.11.
Private network Hide DB and workers from the internet VPC, subnets, security groups, NAT, S3 endpoint Only the load balancer is public.
Store container images A private registry ECR Works with IAM, CodeBuild and EKS.
Auto deploy from Bitbucket CI/CD CodeConnections, CodePipeline, CodeBuild Everything stays inside AWS.
Same setup every time Infrastructure as code Terraform with state in S3 UAT and prod from the same code.

This is the target picture:

Why two ways to run containers? The CTO asked for EKS. But ECS Fargate is simpler and cheaper. It has no cluster to manage. EKS is more portable, and many employers ask for it. I will build both and compare them in a later post.


9. UAT and production

My rule: same Terraform code, different settings. If UAT is built differently from production, a passing UAT test means little.

Setting UAT Production Why
Availability zones 2 2 (or 3) Both survive losing one zone
NAT gateways 1, shared 1 per zone NAT costs a lot. UAT can accept an outage.
Database size db.t4g.micro, one zone db.t4g.medium or bigger, Multi-AZ Failover matters only in prod
Database delete protection off on Easy to remove UAT. Safe prod.
Database backups kept 1 day 14 days
App copies (min / max) 1 / 2 2 / 10 Prod always has a spare
Simulator worker on off Prod gets real driver events
Log retention 7 days 90 days Cost vs audit
SES email Sandbox (verified addresses only) Production access
Deploy Automatic on merge to main Manual approval after UAT passes
Alerts Email to the dev team Email and Slack on-call
Invoice storage Short About 7 years, move to Glacier Finance rules

In a real company, I would put UAT and prod in two separate AWS accounts. This is safer. To keep this series cheap, I will use one account with two clearly separate environments. They will have separate networks, separate state files, and uat- and prod- name prefixes. I will point out where you would split accounts.

The Terraform folder will look like this (I build it in the next post):

infra/
├── bootstrap/          the bucket that stores Terraform state
├── modules/            network, ecr, rds, sqs, ecs, eks ... written once
└── envs/
    ├── uat/            small and cheap
    └── prod/           safe and strong
Enter fullscreen mode Exit fullscreen mode

Next: Tutorial 2

I will set up my AWS account safely (SSO and a budget alarm). I will learn Terraform properly. Then I will build the shared base for UAT and production: remote state, network (VPC), ECR, and S3.

Top comments (3)

Collapse
 
gautamtalksdev profile image
Gautam K •

Moving the PDF and email onto a queue fixes the 8 second requests, and it brings a new duplicate risk: SQS standard queues deliver at least once, so a worker that crashes after sending the email but before deleting the message will send it again. The customer retry you saw on 11.11 can also enqueue the same job twice.

Does the worker record a per parcel key for the invoice email before sending, so a second delivery of the same message becomes a no op?

Collapse
 
sgwebfreelancer_d7d0ccf06 profile image
SGWebFreelancer •

Good catch, and no: my first version only checked Status == "Sent" before sending and saved it after, so a crash in between would re-send. It also created a new invoice on every API call, so the customer retry from 11.11 would double up.

I’ve changed it: a unique index on ParcelId (one invoice per parcel, so retries return the same one), and the worker now claims the email with an atomic update and a 45-second lease before sending. The lease is shorter than the visibility timeout so another worker can take over after a crash.

The crash-after-send gap can’t be fully closed with an external email service. I’ve kept it harmless instead, since the duplicate links to the same PDF. I added a section to the post explaining this, so thanks for raising it.

Collapse
 
gautamtalksdev profile image
Gautam K •

A unique index on ParcelId plus an atomic claim with a lease shorter than the visibility timeout is a solid fix, and thanks for adding the section to the post. Making the leftover gap harmless is the honest answer. Some email APIs accept an idempotency key on the send call; if yours does, passing the ParcelId there would close most of what's left.