Inventory without overselling
TeselaDB
TeselaDB team
30 minIntermediate
Take the stock and create the order in a single transaction so two purchases at once never sell the same unit.
When several customers buy the last product at once, each purchase has to take the stock and create its order without stepping on the others. With a multi-document transaction both writes happen together or neither does, whether the stock and the orders are in the same range or in ranges on different nodes.
What you will build
A Node.js placeOrder function that takes units only if there are enough left and creates the order in the same
transaction. Five purchases at once of a product with three units leave three orders and the stock at zero.
Requirements
- Node.js 18 or later with the
mongodbpackage. - A TeselaDB cluster and a user with
readWriteon theshopdatabase. - The connection string in the
TESELADB_URIenvironment variable.
1. Take the stock with a condition
The available: { $gte: quantity } filter makes the update change the document only if there are enough units left.
If nothing changes, the function throws an error and the whole transaction is undone.
2. Write the purchase in a transaction
withTransaction retries the transaction when it collides with another one that touched the same document, so you do
not write the retries yourself. Save the program as inventory.mjs:
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.TESELADB_URI);
const db = client.db("shop");
// Takes the units from the stock and writes the order in one transaction: both happen, or neither.
async function placeOrder(sku, quantity, customer) {
const session = client.startSession();
try {
return await session.withTransaction(async () => {
const taken = await db
.collection("stock")
.updateOne({ _id: sku, available: { $gte: quantity } }, { $inc: { available: -quantity } }, { session });
if (taken.modifiedCount === 0) throw new Error(`not enough stock for ${sku}`);
await db.collection("orders").insertOne({ sku, quantity, customer, createdAt: new Date() }, { session });
});
} finally {
await session.endSession();
}
}
await db.collection("stock").insertOne({ _id: "chair-red", available: 3 });
const customers = ["Ana", "Luis", "Marta", "Pablo", "Sara"];
const results = await Promise.allSettled(customers.map((customer) => placeOrder("chair-red", 1, customer)));
const placed = results.filter((result) => result.status === "fulfilled").length;
const { available } = await db.collection("stock").findOne({ _id: "chair-red" });
console.log(`${placed} orders placed, ${available} left`);
await client.close();Try it
Run node inventory.mjs against an empty database. The five purchases go at once, three find stock and two fail with
not enough stock:
3 orders placed, 0 leftClean up
db.getSiblingDB("shop").stock.deleteOne({ _id: "chair-red" });
db.getSiblingDB("shop").orders.deleteMany({ sku: "chair-red" });From mongosh, the first command deletes the test product and the second deletes its orders.
Next steps
- Transactions, with what snapshot isolation guarantees.
- Retries and transient errors, to write the retries by hand.