sehaj/learn
System design/07 Async work and streams

07.01 · concept · 8 min

Why move work off the request path

What belongs in the request a user waits for, and what can happen after. How to decide, the difference between a job and an event, and what a queue really does to overload: turns it into delay.

When Snip launched, creating a link was one INSERT. Then the paid plan added features, and each one was bolted on to the same request. Today, POST /links does all of this before it answers:

StepTypical timeFails when
Insert the link5 msThe database is down
Check the URL against the safety service (02.06 Async workers and queues)200 ms, sometimes 2 sThe service is down
Fetch the page's title and image for a preview300 ms to 5 sThe site is slow or blocks bots
Render a QR code image and upload it80 msObject storage is slow
Call the owner's webhook ("a link was created")Whatever their server takesTheir server is down
Update the search index for the dashboard30 msThe index is being rebuilt

The p99 for creating a link is now over 5 seconds, and any one of five other systems being down makes it fail. The owner wanted a short code. Everything else is something that should happen because a link was created, not something the owner needs before they can carry on.

Deciding what stays in the request

Ask of each step: does the user need its result to continue?

  • Yes, and it must be correct now → do it in the request. The insert stays: without it there's no link.
  • No, but it must eventually happen → move it to the background, reliably: the preview, the QR code, the index.
  • No, and it's someone else's system → definitely background: the webhook. Never let a customer's slow server slow down your API.
  • It decides whether the request is allowed → think hard. The safety check could block the request, but Snip decided in 02.06 Async workers and queues that a few seconds of exposure beats making every owner wait.

After the move, POST /links does the insert and records that background work is needed, and answers in about 10 ms. The work happens a moment later, and the dashboard shows "preview loading" until it's done.

Jobs and events

There are two ways to describe the background work, and the difference shapes the rest of this module.

  • A job (a command) says do this: "fetch the preview for x7Kp". It has one intended worker, and once it's done it can be forgotten. Job queues are built for this (07.05 A job queue on Postgres with SKIP LOCKED).
  • An event says this happened: "link x7Kp was created". It has no idea who cares. The preview worker, the QR worker, the webhook sender and the search indexer can each react to it, and next year's new feature can too, without changing the code that creates links. Logs are built for this (07.02 Queues vs logs).

Events decouple the producer from its consumers; jobs make the intended work explicit. Snip uses both: link creation publishes one link.created event, and the services that care turn it into their own jobs.

A queue turns overload into delay

A queue doesn't make work cheaper. Workers still have to do every bit of it. What it changes is when. If links arrive faster than workers can fetch previews, the queue grows instead of requests failing. That's usually what you want for a burst, and a trap for a lasting overload.

Little's law connects the numbers: the average number of items waiting equals the arrival rate times the average wait. If previews arrive at 350 a second and each sits in the queue for 2 seconds on average, about 700 are waiting at any moment. Turn it round: a backlog of 70,000 at 350 a second means a new preview waits 200 seconds. Queue length alone tells you little; queue length divided by the drain rate tells you how late the work is.

So watch two numbers for every queue:

  • Age of the oldest message: how late the work is. This is the one to alert on, because it's what users feel.
  • Depth: how many messages are waiting. Useful for scaling workers, alarming only when it keeps growing.

And give every queue a limit. A queue that grows without bound eventually runs out of memory or disk, and then it can't accept anything at all, turning a slow consumer into an outage for the producer. When the limit is hit, shed or delay the least important work (03.11 Backpressure and load shedding).

What you give up

Seen in production

Slack

2017

Job queue

Slack runs work that's too slow for a web request as asynchronous jobs. On its busiest days the system processes over 1.4 billion jobs, at a peak of 33,000 a second. It originally kept jobs in Redis, polled by pools of workers. During an outage, a slowdown in the database made jobs run slower, Redis reached its maximum memory, and it could no longer enqueue new jobs. Slack's fix was to add Kafka in front of Redis rather than replace Redis: a stateless service, Kafkagate, enqueues jobs to Kafka, and another, JQRelay, relays them from Kafka to the Redis cluster the workers read.

What we foundBackground work at huge scale, and an outage when the queue itself filled up and couldn't accept new jobs.

Scaling Slack's Job Queue (Slack Engineering, updated 2020)slack.engineering ↗
Predict first

Snip's preview queue holds 90,000 messages and drains 300 a second. The dashboard says 'preview loading' for new links. Roughly how long will a new link wait for its preview?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqMust-knowConcept

When would you process work asynchronously instead of in the request?

Report a mistakeEdit this page