Ticketing System : A Case Study of MongoDB Transactions and Atomicity

Liberocks
6 min readFeb 28, 2020
Photo by Nick Hillier on Unsplash

A ticketing system holds crucial duty on guaranteeing each taken slot belongs to exactly a passenger. This typical system under the hood requires atomicity and rollback capabilities of the database; where in MongoDB, rollback is analogous to transactions. In this article, I want to discuss these MongoDB capabilities through ticketing-system case study.

TL;DR

All the code in this article can be seen in this repository : medium-mongodb-atomicity-transaction.

Definition And Concept

I start this post with definitions of atomicity and transactions. According to MongoDB Documentation, atomicity is described as

In MongoDB, a write operation is atomic on the level of a single document, even if the operation modifies multiple embedded documents within a single document.

I found an article nicely recapped the atomicity operation in Mongoose in on table as following,

https://masteringjs.io/tutorials/mongoose/update

In general, you should use save() to update a document in Mongoose, unless you need an atomic update.

While transactions is described as,

For situations that require atomicity of reads and writes to multiple documents (in a single or multiple collections), MongoDB supports multi-document transactions. With distributed transactions, transactions can be used across multiple operations, collections, databases, documents, and shards.

The transactions is noted by these interrelated operations : establishing session, starting transaction, committing transaction, aborting transaction, and ending session. Each operation in a transaction use same session, thus the session has to be started in the very beginning. Then the transaction is started afterwards. The business process happens in this stage. If the process runs as we want, the db operation needs to be committed. And if in the middle of process something unwanted happens, we need to immediately abort the transaction and consequently all the db operation won’t even touch the db. Finally, we end the transaction no matter the status of the process.

The System Overview

My version of basic ticketing system includes at least two function : creating schedule and booking the slot. I call them as schedule-endpoint and ticket-endpoint. While the schedule-endpoint demonstrates transaction operation, the ticket-endpoint demonstrate both of atomic and transaction operations.

The schedule-endpoint will create the schedule for appointed train and slots afterwards. The schedule won’t be committed to db if there is any of slot creation fails and vice-versa.

As for ticket-endpoint, the slot booking is queued for concurrent requests. If the request successfully grab the slot, the ticket will be made. Otherwise, if the request unsuccessfully grab the slot, the ticket won’t be made and the request is rejected.

To accommodate these needs, here are the db schema designs; nothing specials.

  • Schedule schema
const mongoose = require('mongoose')
const { Schema } = mongoose
const schema = new Schema({
name: { type: String, required: true },
quota: { type: Number, required: true },
departure: { type: Date, required: true }
})
module.exports = mongoose.model('Schedule', schema)
  • Slot Schema
const mongoose = require('mongoose')
const { Schema } = mongoose
const schema = new Schema({
scheduleId: { type: mongoose.ObjectId },
seatNumber: { type: Number, required: true },
available: { type: Boolean, default: true },
bookedBy: { type: mongoose.ObjectId, default: null }
})
module.exports = mongoose.model('Slot', schema)
  • Ticket Schema
const mongoose = require('mongoose')
const { Schema } = mongoose
const schema = new Schema({
seatNumber: { type: Number, required: true },
passengerId: { type: mongoose.ObjectId, required: true },
scheduleId: { type: mongoose.ObjectId, required: true },
slotId: { type: mongoose.ObjectId, required: true }
})
module.exports = mongoose.model('Ticket', schema)

Schedule Endpoint

Multi-document creation for schedule and slots
app.post('/schedule', async (req, res, next) => {
const session = await mongoose.startSession()
session.startTransaction()
try {
// Schedule collection
const data = {
name: req.body.name,
quota: req.body.quota,
departure: DateTime.fromFormat(req.body.departure, 'yyyy-MM-dd HH:mm ZZZ').toJSDate()
}
const schedule = await Schedule.create([data], { session })
// Slot collection
const slots = [...Array(data.quota).keys()].map(i => ({
scheduleId: schedule[0]._id,
seatNumber: i + 1
}))
await Slot.create(slots, { session })
await session.commitTransaction()
res.json({ data: schedule[0] })
} catch (error) {
await session.abortTransaction()
next(error)
} finally {
session.endSession()
}
})

Here are some note on code above:

  • First we collect user request data by parsing request body. This request contains schedule name, slot quota, and departure date. This data is then used to make the schedule document. If the schedule is smoothly created, we proceed creating slot as much as specified quota.
  • The slots are created accordingly regarding to incremental seat number. Other than seat number, slots also use schedule object id as reference. Then the create operation creates the slots at once.
  • If there is no exception thrown, we agree to commit these documents. Otherwise, the transaction is aborted and the error is informed to requester.

Ticket Endpoint

Atomic operation on booking slot
app.post('/ticket', async (req, res, next) => {
const session = await mongoose.startSession()
session.startTransaction()
try {
// Find slot
const query = {
scheduleId: req.body.scheduleId,
seatNumber: req.body.seatNumber,
available: true
}
const slot = await Slot.findOneAndUpdate(query, {
$set: {
bookedBy: req.body.passengerId,
available: false
}
}, { useFindAndModify: false })
if (!slot) throw new BadRequest('Slot is not available')
// Create ticket
const ticket = await Ticket.create([{
seatNumber: req.body.seatNumber,
passengerId: req.body.passengerId,
scheduleId: req.body.scheduleId,
slotId: slot._id
}], { session })
await session.commitTransaction()
res.json({ message: 'Ticket is successfully booked', ticket: ticket[0] })
} catch (error) {
await session.abortTransaction()
next(error)
} finally {
session.endSession()
}
})

Here are some note on code above:

  • As shown in table above, we could use findOneAndUpdate operation for conducting atomic operation. We won’t get this atomicity behaviour if for instance we choose to update the slot as below
const query = {
scheduleId: req.body.scheduleId,
seatNumber: req.body.seatNumber,
available: true
}
const slot = await Slot.findOne(query)
slot.available = false
slot.bookedBy = req.body.passengerId
await slot.save()
  • We try to find specified slot with particular query. If there are concurrent request at same time, only the first request gets the document, while the subsequent request won’t find the specified slot due the slot availability has been updated to be false.
  • In the end, if slot is managed to be booked by user whom passengerId is specified, the ticket will be issued.

Testing

Assume we work using microservices paradigm where this ticketing service runs in multiple container. To cut the long short, we run the express using bash background process instead of docker/kubernetes. For example, we run 5 container at once.

for port in {3000..3004}; do
nodemon index ${port} &
done

Then create the schedule only on one container :

curl -X POST -H "Content-Type: application/json" -d '{"name":"Argo Wilis","quota":4,"departure":"2020-02-29 20:07 +0700"}' 0.0.0.0:3000/schedule

yielding response:

{
"data": {
"_id":"5e5852d781636c3e4bc72083",
"name":"Argo Wilis",
"quota":4,
"departure":"2020-02-29T13:07:00.000Z",
"__v":0
}
}

Then simultaneously book the same seat number:

curl -X POST -H "Content-Type: application/json" -d '{"scheduleId":"5e584fc617f7d2279ec1f1f2","seatNumber":4,"passengerID":"5e584ba2b76ce81243782255"}' 0.0.0.0:3000/ticket & curl -X POST -H "Content-Type: application/json" -d '{"scheduleId":"5e584fc617f7d2279ec1f1f2","seatNumber":4,"passengerID":"5e584ba2b76ce81243782256"}' 0.0.0.0:3001`/ticket & curl -X POST -H "Content-Type: application/json" -d '{"scheduleId":"5e584fc617f7d2279ec1f1f2","seatNumber":4,"passengerID":"5e584ba2b76ce81243782257"}' 0.0.0.0:3002/ticket & curl -X POST -H "Content-Type: application/json" -d '{"scheduleId":"5e584fc617f7d2279ec1f1f2","seatNumber":4,"passengerID":"5e584ba2b76ce81243782258"}' 0.0.0.0:3003/ticket & curl -X POST -H "Content-Type: application/json" -d '{"scheduleId":"5e584fc617f7d2279ec1f1f2","seatNumber":4,"passengerID":"5e584ba2b76ce81243782259"}' 0.0.0.0:3004/ticket &

resulting response :

> {"message":"Slot is not available"}
> {"message":"Slot is not available"}
> {"message":"Slot is not available"}
> {"message":"Slot is not available"}
> {
"message":"Ticket is successfully booked",
"ticket": {
"_id":"5e585f8918fdd40a6bf343ee",
"seatNumber":4,
"passengerId":"5e584ba2b76ce81243782258",
"scheduleId":"5e5852d781636c3e4bc72083",
"slotId":"5e5852d881636c3e4bc72087",
"__v":0
}
}

Summary and Lesson Learned

While MongoDB provides atomicity and transactional operation with battery included, we have to design the process to comply how these operation works.

--

--

Liberocks

Software Engineer in Healthcare and Pharmaceutical Industry