Skip to content
wannacode

Events and queues

In short: An event says "this happened" and goes into a queue from which each subscriber takes it on its own; delivery is at least once, so the consumer must be idempotent.

Problem

The slow email slows down the payment

When a customer pays, the shop does four things: charges the card, reserves the stock in the warehouse, sends a confirmation email and records the payment in accounting. The checkout calls them one after another:

const card = new Service("card", 2);
const warehouse = new Service("warehouse", 3);
const mailer = new Service("email", 25); // slow SMTP: 25 ticks
const ledger = new Service("accounting", 4);
function pay(order, total) {
const start = Service.clock;
card.call(`charges ${total} €`);
warehouse.call(`reserve stock for ${order}`);
mailer.call(`confirmation for ${order}`);
ledger.call(`${total} € for ${order}`);
const waited = Service.clock - start;
console.log(`✓ ${order}: the customer waits ${waited} ticks`);
}

Every "service" here is an object with a counter: every call adds its ticks. Ticks are the time in the program, instead of seconds. The email goes through a slow mail server and costs 25 ticks:

class Service {
static clock = 0; // the ticks that pass
constructor(name, cost, failures = 0) {
this.name = name;
this.cost = cost;
this.failures = failures; // a recorded script: how many times in a row it fails
}
call(what) {
Service.clock += this.cost;
if (this.failures > 0) {
this.failures--;
throw new Error(`${this.name} is not responding`);
}
console.log(`${this.name}: ${what}`);
}
}

The first payment goes through. Then the email fails once:

pay(1001, 120);
mailer.failures = 1; // the email fails once
try {
pay(1002, 80);
} catch (e) {
console.log(`✗ 1002: ${e.message}`);
}
Output
card: charges 120 €
warehouse: reserve stock for 1001
email: confirmation for 1001
accounting: 120 € for 1001
✓ 1001: the customer waits 34 ticks
card: charges 80 €
warehouse: reserve stock for 1002
✗ 1002: email is not responding

The customer waits 34 ticks, 25 of them for an email that could just as well arrive a minute later. And with order 1002 the card is charged and the stock is reserved, but the customer sees an error, because the mail server does not answer. They press "Pay" again.

There is a third pain. The checkout knows everyone who reacts to it. Tomorrow marketing wants loyalty points and the mobile app wants a notification. Every new line is a change in the checkout code.

The idea

An event: this already happened

The checkout should not tell the warehouse "reserve", the email "send" and accounting "record". It is enough to announce what happened: "order 1001 is paid". This is a domain event: a fact in the language of the shop, in the past tense, with a name like OrderPaid. It carries its own id and the data the others need: the order number, the total, the quantity. Once it has happened, it does not change.

The event goes into a message queue. The program that keeps the queues is a broker. Everyone who wants to know about payments subscribes. The broker keeps a separate queue for every subscriber and puts a copy of the event in each. This is publish/subscribe: the checkout publishes, the subscribers receive, and neither side knows about the other.

You saw the idea in Observer: whoever wants to know subscribes. The difference is the queue in the middle. Observer calls the subscribers right away, in the same call, and waits for each of them. Here the checkout leaves the event in the queue and answers the customer. The subscribers take it when they can, like the packers in producer and consumer.

The checkout and the warehouse can now be separate modules, even separate services, as in the previous lesson: they talk only through events.

See it

The broker in memory

A real broker is a separate program (Kafka, RabbitMQ). Here it is a class in memory with a map of queues, so you can see the mechanism. The event:

// OrderPaid: { id, order, total, qty }, a plain object nobody changes
JavaScript has no record, so the event is a plain object. Nobody changes it, by agreement. Object.freeze turns the agreement into a rule.

The broker. A subscriber gives a name and a function that handles the event:

class Broker {
constructor(network) {
this.queues = new Map(); // subscriber → its own queue
this.handlers = new Map();
this.network = network;
}
subscribe(name, handler) {
this.queues.set(name, []);
this.handlers.set(name, handler);
}
publish(event) {
for (const queue of this.queues.values()) {
queue.push(event); // a copy for every subscriber
}
}
// one round: each subscriber gets the first in its queue
deliver() {
let any = false;
for (const [name, queue] of this.queues) {
if (queue.length === 0) continue;
any = true;
try {
this.handlers.get(name)(queue[0]);
} catch (e) {
console.log(` ✗ ${e.message}: stays in the queue`);
continue;
}
// the ack is lost: the event stays and comes again
if (this.network.ackLost(name)) continue;
queue.shift(); // ack: take it out of the queue
}
return any;
}
}

The important part is in deliver. The subscriber gets the first event in its queue, but the event leaves the queue only when the subscriber finishes without an error. The confirmation "I am done" is called an ack (acknowledgement). If the subscriber throws, there is no ack and the event stays for the next round. A lost ack comes from the class Network: a recorded script of whose ack gets lost. Here it loses nothing; on the next card it does.

class Network {
constructor(...lost) {
this.lost = lost; // a recorded script: whose ack is lost
}
ackLost(name) {
const i = this.lost.indexOf(name);
if (i < 0) return false;
this.lost.splice(i, 1);
console.log(` ✗ ack from ${name} is lost`);
return true;
}
}

The checkout now only charges the card and publishes:

const card = new Service("card", 2);
function pay(order, total, qty) {
const start = Service.clock;
card.call(`charges ${total} €`);
broker.publish({ id: `evt-${order}`, order, total, qty });
const waited = Service.clock - start;
console.log(`✓ ${order}: the customer waits ${waited} ticks`);
}

Two orders, and the email fails once, as in the problem:

const mailer = new Service("email", 25, 1);
const ledger = new Service("accounting", 4);
const broker = new Broker(new Network());
const warehouse = new Warehouse();
broker.subscribe("warehouse", (e) => warehouse.on(e));
broker.subscribe("email", (e) => mailer.call(`confirmation for ${e.order}`));
broker.subscribe("accounting", (e) => ledger.call(`${e.total} € for ${e.order}`));
pay(1001, 120, 2);
pay(1002, 80, 1);
while (broker.deliver()) {}
Output
card: charges 120 €
✓ 1001: the customer waits 2 ticks
card: charges 80 €
✓ 1002: the customer waits 2 ticks
warehouse: 2 pcs for 1001, 8 left
✗ email is not responding: stays in the queue
accounting: 120 € for 1001
warehouse: 1 pcs for 1002, 7 left
email: confirmation for 1001
accounting: 80 € for 1002
email: confirmation for 1002

The customer waits 2 ticks for both orders. The email is still slow and still fails, but now that is its own problem: the event stays in its queue and comes again in the next round. The warehouse and accounting never notice.

Look at the order too. The confirmation for 1001 goes out after the warehouse has handled 1002. Every queue moves at its own pace. So a subscriber must not count on another subscriber having done its part already.

The idea

At least once means sometimes twice

The broker removes the event from the queue only after the ack. What happens if the warehouse takes out the stock, but the ack gets lost on the network? The broker cannot tell "the warehouse failed before it started" from "the warehouse finished, but could not say so". So as not to lose an order, it delivers the event again.

The script loses the ack from the warehouse once. The warehouse is naive: every event takes out the stock:

class Warehouse {
stock = 10;
on(e) {
this.stock -= e.qty;
console.log(
`warehouse: ${e.qty} pcs for ${e.order}, ${this.stock} left`,
);
}
}
const mailer = new Service("email", 25, 0);
const ledger = new Service("accounting", 4);
const broker = new Broker(new Network("warehouse"));
const warehouse = new Warehouse();
broker.subscribe("warehouse", (e) => warehouse.on(e));
broker.subscribe("email", (e) => mailer.call(`confirmation for ${e.order}`));
broker.subscribe("accounting", (e) => ledger.call(`${e.total} € for ${e.order}`));
pay(1001, 120, 2);
while (broker.deliver()) {}
Output
card: charges 120 €
✓ 1001: the customer waits 2 ticks
warehouse: 2 pcs for 1001, 8 left
✗ ack from warehouse is lost
email: confirmation for 1001
accounting: 120 € for 1001
warehouse: 2 pcs for 1001, 6 left

One order for 2 items, and 4 are missing from the warehouse. The stock is wrong, and the shop will refuse the next order even though the goods are on the shelf.

A broker can promise one of two things:

  • at most once: it removes the event before the subscriber works. No duplicates, but if the subscriber fails, the event is lost;
  • at least once: it removes it after the ack. Nothing is lost, but sometimes it arrives twice.

"Exactly once" across a network is something a broker cannot promise on its own. That is why almost every system picks "at least once", and the receiver catches the duplicate.

See it

An idempotent consumer

A consumer for whom getting the same event a second time changes nothing is idempotent (an idempotent consumer). The warehouse remembers the ids of the events it has already handled and drops the duplicate:

class Warehouse {
stock = 10;
// the ids of the events already handled
seen = new Set();
on(e) {
if (this.seen.has(e.id)) {
console.log(`warehouse: ${e.id} is a duplicate`);
return;
}
this.stock -= e.qty;
this.seen.add(e.id);
console.log(
`warehouse: ${e.qty} pcs for ${e.order}, ${this.stock} left`,
);
}
}

publish(event) {
for (const queue of this.queues.values()) {
queue.push(event); // a copy for every subscriber
}
}
// one round: each subscriber gets the first in its queue
deliver() {
let any = false;
for (const [name, queue] of this.queues) {
if (queue.length === 0) continue;
any = true;
try {
this.handlers.get(name)(queue[0]);
} catch (e) {
console.log(` ✗ ${e.message}: stays in the queue`);
continue;
}
// the ack is lost: the event stays and comes again
if (this.network.ackLost(name)) continue;
queue.shift(); // ack: take it out of the queue
}
return any;
}
Step by step, in words
  1. The checkout charges the card and publishes OrderPaid evt-1001: "order 1001 is paid". The customer waits only 2 ticks.
  2. The broker puts a copy in each queue: warehouse, email and accounting. The checkout has already answered.
  3. In each round every subscriber gets the first event in its own queue. The event stays in the queue until the ack.
  4. The warehouse takes out 2 (8 left) and writes evt-1001 in seen. Email and accounting do their part.
  5. The acks from email and accounting arrive, and the event leaves their queues. The ack from the warehouse is lost on the way.
  6. The broker does not know the warehouse did the work, so it delivers evt-1001 again. This is "at least once".
  7. evt-1001 is already in seen, so the warehouse drops it. The stock stays 8, not 6.
  8. This time the ack arrives and the queue is empty. Delivered at least once, handled exactly once.
const mailer = new Service("email", 25, 0);
const ledger = new Service("accounting", 4);
const broker = new Broker(new Network("warehouse"));
const warehouse = new Warehouse();
broker.subscribe("warehouse", (e) => warehouse.on(e));
broker.subscribe("email", (e) => mailer.call(`confirmation for ${e.order}`));
broker.subscribe("accounting", (e) => ledger.call(`${e.total} € for ${e.order}`));
pay(1001, 120, 2);
while (broker.deliver()) {}
Output
card: charges 120 €
✓ 1001: the customer waits 2 ticks
warehouse: 2 pcs for 1001, 8 left
✗ ack from warehouse is lost
email: confirmation for 1001
accounting: 120 € for 1001
warehouse: evt-1001 is a duplicate

The broker still delivers twice, but the warehouse takes out the stock once. The id is written after the work: if the work fails halfway, the event comes again and is handled.

Some operations are idempotent on their own: "the status of 1001 is paid" is the same the second time. "Stock minus 2" is not. Those need an id. In a real system seen is a table in the same database as the stock, and the subtraction and the id are written in one transaction. Otherwise a crash between the two gives a wrong result again. What happens when the database write and sending the event are in different systems is in the next lesson.

Try it

Predict the output

The warehouse is idempotent. This time the script loses the ack from the email. What does the program print?

const mailer = new Service("email", 25, 0);
const ledger = new Service("accounting", 4);
const broker = new Broker(new Network("email"));
const warehouse = new Warehouse();
broker.subscribe("warehouse", (e) => warehouse.on(e));
broker.subscribe("email", (e) => mailer.call(`confirmation for ${e.order}`));
broker.subscribe("accounting", (e) => ledger.call(`${e.total} € for ${e.order}`));
pay(1001, 120, 2);
while (broker.deliver()) {}
Answer

Try it

Find the bug

A colleague rewrites the warehouse, and the test with the lost ack still shows the stock taken out twice. Which line has the bug?

Pick the line with the bug.

The idea

When events hurt

Events are not free. Do not use them when:

  • you need the answer right now. "Is it in stock?" before the payment is a question, not an event. If you cannot go on without the answer, call the other module;
  • the system is small and in one process. A call or Observer is enough. A broker is one more program that has to run, be watched and be updated.

And know the price when you do use them:

  • the flow is no longer in one place. What happens after the payment cannot be read in one function; you collect it from the subscribers. That is why every event carries an id you can later search for in the logs;
  • the others learn later. The customer pays and opens "My orders" right away, and the invoice is not there yet. This is called eventual consistency. The screen should say so honestly: "the invoice arrives within a minute";
  • order is not promised. You saw it in the program: the queues move at their own pace. If order matters (paid, then cancelled), the events of one order must go through one queue and one consumer;
  • duplicates are your job. Every subscriber must be idempotent.

How a real broker is run and watched in production (a cluster, disks, monitoring) is a topic for the DevOps course ZeroToOps.

More precisely, if you are curious

Real brokers. Kafka keeps the events as a log that each subscriber reads from its own position. There the ack is the remembered position (the offset). RabbitMQ has queues and an ack for every message, as in the lesson. There are also Redis Streams, Amazon SQS and SNS, Azure Service Bus and Google Pub/Sub. In Spring a subscriber is a method with @KafkaListener or @RabbitListener. ASP.NET Core projects often use MassTransit. Python has clients like confluent-kafka and aio-pika, and Celery is a task queue. Node.js has kafkajs and amqplib, and NestJS has a messaging module.

The dead letter queue. An event that fails every time (broken data) must not spin in the queue forever. After a few attempts the broker moves it to a separate queue where a person looks at it.

Exactly once. Kafka has an "exactly once" mode, but it holds inside Kafka: read, process, write back to Kafka. As soon as the effect is outside (an email, a payment, a database), the duplicate is still your job.

Event or command. OrderPaid is an event: many subscribers, none obliged. ReserveStock is a command: one receiver who must carry it out. Both can go through a queue, but the names state different intentions.

How it is in the languages. In JavaScript a subscriber is a function. There is no interface, and the event is a plain object. An array is the queue: shift() removes the first element but moves the rest, which is slow for a long queue. Real broker clients work with async functions.

Exercise

A notification in the app

Solve it in your editor.

The mobile app wants an "order paid" notification. Add a fourth subscriber, "app", without touching the checkout code. It must be idempotent, like the warehouse. The script loses its ack once:

Output
card: charges 120 €
✓ 1001: the customer waits 2 ticks
warehouse: 2 pcs for 1001, 8 left
email: confirmation for 1001
accounting: 120 € for 1001
app: notification for 1001
✗ ack from app is lost
app: evt-1001 is a duplicate

More tasks

  • A cancelled order. Add an OrderCancelled event. The warehouse returns the stock, but only if it has handled OrderPaid for the same order. What happens if the cancellation arrives before the payment?
  • A dead letter queue. If a subscriber fails three times in a row on the same event, the broker moves it to a dead list and goes on with the next one.
  • Loyalty points. Add a subscriber that gives 1 point for every 10 € of the total. What must it remember so as not to give the points twice?

Test

Check yourself

1. Which name is a domain event and not a command?
2. The ack from the warehouse is lost on the way. What does the broker do?
3. Why does the warehouse write the id into seen AFTER it takes out the stock, and not before?
4. When is it better to call the other module directly instead of publishing an event?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·