I want to remind you that this post does not follow a chronological order, in the future I may make two new posts, one about an optimistic lock in MongoDB and another about a comparison between the two locking models.
I will use the NoSQL database (MongoDB) together Node.js and Mongoose to explain how to work with a document lock
Enjoy reading! 😃
- What is atomic?
- Explain use case
- Quickly Explanation
- Coroutines
- Concurrency
- Parallelism
- Difference
- Conventional Code
- Solution Code
- Conclusion
I recently studied locks to implement in a bank project where my application must be able to make a transfer atomically. So I would like to show you how the implementation went, and the impact it had on the application.
Pre-requirements to understand this article
- What is NoSQL
- Already have used Mongoose
- Basic concepts of Node.js
- Understand what is Parallelism
- Understand what is Concurrency
- Understand what is Idempotency Key (Optional)
You don't need to know what idempotency is because in this example I won't perform any tests on how idempotency can be used, but it's good to know.
What is atomic? Atomic comes from the word Atom, that means something indivisible. In the science of computing, the term is used to reference when something is guaranteed completely done.
Explain use case think in a scenario where I need to ensure that a bank account don't can be had an inconsistency balance.
You now must be asked yourself - But Hállex, how to test if the account balance is consistency? You can use the parallelism or concurrency.
Coroutines: Are a way of execution to be suspended and resumed, generalizing subroutines for cooperative multitasking. It is similar to parallelism although, It's not the same thing.
Concurrency: Is that it is a way to handle multitask execution, sharding computing resources. Normally, the concurrency concept brings with it the terminology Coroutines.
Parallelism: Is a way of execution one or more tasks in the simultaneously.
Difference: While coroutines handle with the multitasks in a concurrent way dividing the machine resource between the tasks when they are available, the parallelism cares with the execution of all tasks in the same time.
schema.ts
import mongoose, { type Document } from 'mongoose'
enum AccountStatus {
PENDING = 'PENDING',
ACTIVED = 'ACTIVED'
}
export type AccountModel = {
publicId: number
userId: string
balance: number
status: string
locked: boolean
lockTimestamp: number
lockDuration: number
createdAt: Date
updatedAt?: Date | null
}
export type AccountModelDocument = AccountModel & Document
export const Schema = new mongoose.Schema<AccountModelDocument>({
balance: {
type: Number,
description: 'Balance amount from account'
},
status: {
type: String,
values: [AccountStatus.ACTIVED, AccountStatus.PENDING],
description: 'Status from account'
}
}, {
collection: 'Account',
timestamps: true
})
export const Account = mongoose.model<AccountModel & Document>(
'Account',
Schema
)
// transaction schema and model
import mongoose, { Schema, type Document, type Model } from 'mongoose'
export enum TransactionStatusEnum {
PENDING = 'PENDING',
PAID = 'PAID',
FAILED = 'FAILED'
}
export enum TransactionTypeEnum {
CREDIT = 'CREDIT',
DEBIT = 'DEBIT',
PIX = 'PIX'
}
export type TransactionModel = {
publicId: number
amount: number
originSenderAccountId: mongoose.Types.ObjectId
destinationReceiverAccountId: mongoose.Types.ObjectId
status: TransactionStatusEnum
type: TransactionTypeEnum
createdAt: Date
updatedAt: Date | null
idempotencyKey: string
}
export type TransactionModelDocument = TransactionModel & Document
const transactionSchema = new mongoose.Schema<TransactionModelDocument>(
{
publicId: Number,
amount: Number,
status: {
type: String,
values: [
TransactionStatusEnum.PENDING,
TransactionStatusEnum.FAILED,
TransactionStatusEnum.PAID
],
description: 'Status from transaction: "PENDING", "FAILED" or "SUCCESS"'
},
type: {
type: String,
values: [
TransactionTypeEnum.PIX,
TransactionTypeEnum.CREDIT,
TransactionTypeEnum.DEBIT
],
description: 'Type of operation "PIX", "CREDIT" or "DEBIT"'
},
destinationReceiverAccountId: {
type: Schema.Types.ObjectId,
description: 'Destination account that will receive the amount'
},
originSenderAccountId: {
type: Schema.Types.ObjectId,
description: 'Origin account that will send the amount sent'
},
idempotencyKey: {
type: String,
description: 'The unique Idempotency key to identify the transaction'
}
},
{
collection: 'Transaction',
timestamps: true
}
)
transactionSchema.index({
destinationReceiverAccountId: 1,
originSenderAccountId: 1,
idempotencyKey: 1,
amount: 1
})
export const Transaction: Model<TransactionModelDocument> = mongoose.model(
'Transaction',
transactionSchema
)
TransactionService.ts
import { AccountModel, Transaction } from './schemas'
const idempotencyKey = 'some-idempotency-key'
const originAccountId = 1
const destAccountId = 1
const originAccount = await AccountModel.findOne({
_id_: originAccountId
})
const destinationAccount = await AccountModel.findOne({
publicId: destAccountId
})
if (!originAccount || !destinationAccount) {
return {
error: 'Origin or destination account not found',
success: null,
transaction: null
}
}
const alreadyTransaction = await Transaction.findOne({
idempotencyKey
})
if (alreadyTransaction) {
return {
success: 'Transfer completed successfully',
cache: true,
error: null,
transaction: {
...alreadyTransaction,
amount: -transaction.amount,
createdAt: new Date(transaction.createdAt).toISOString().slice(0, 10) // only date YYYY-MM-DD
}
}
}
if (originAccount.balance <= 0) {
return {
success: null,
cache: false,
error: 'Origin account can not transfer because the balance is zero',
transaction: null
}
}
const lockTimestamp = +new Date()
await Account.updateOne({ _id: originAccount._id }, {
$set: {
balance: originAccount.balance + amount,
updatedAt: date
}
})
await Account.updateOne({ _id: destinationAccount._id }, {
$set: {
balance: destinationAccount.balance + amount,
updatedAt: date
}
})
const publicId = generateUniqueIntId()
const transaction = await Transaction.create({
publicId,
amount,
originSenderAccountId: originAccount.id,
destinationReceiverAccountId: destinationAccount.id,
status: 'PAID',
type: 'PIX',
idempotencyKey,
createdAt: new Date(),
updatedAt: null
})
ConsistencyAccountBalance.spec.ts
describe('Account Balance', () => {
it('should be consistency in the balance account while trying two request simultaneously', () => {
// create origin account with balance 1000
// create destination account with balance 1000
// make requests
const request1 = callRequest1({
originId: 1,
destinationId: 2,
transferAmount: 1
}) // asynchronous request
const request2 = callRequest1({
originId: 1,
destinationId: 2,
transferAmount: 2
}) // asynchronous request
console.log(await Promise.allSetled([request1, request2]))
// result:
/*
[
{
status: 'fullfilled',
value: {
data: {
success: 'Transfer successfully',
CreateTransaction: {
originId: 1,
destinationId: 2,
transferAmount: 1,
originBalance: 999,
destinationBalance: 1001,
}
}
}
},
{
status: 'fullfilled',
value: {
data: {
success: 'Transfer successfully',
CreateTransaction: {
originId: 1,
destinationId: 2,
transferAmount: 2,
originBalance: 998,
destinationBalance: 1002,
}
}
}
}
]
*/
const originAcc = await Account.findOne({ _id: 1 })
const destAcc = await Account.findOne({ _id: 2 })
expect(originAcc.balance).toBe(1000 - 3)
expect(destAcc.balance).toBe(1000 + 3)
})
})
You can see problem in test result? The result from transfers are conflict between there, because both are doing execute in parallel, causing the inconsistency in balance, because while the first time the request access to the balance from origin account is 1000, in second time the origin account yet is 1000, because both operation are independent, then yet not have a some time to finish the debit operation from first request.
It is now that I propose a pessimistic lock solution to atomically search the document, how to show the example below:
schema.ts
import mongoose, { type Document } from 'mongoose'
enum AccountStatus {
PENDING = 'PENDING',
ACTIVED = 'ACTIVED'
}
export type AccountModel = {
publicId: number
userId: string
balance: number
status: string
locked: boolean
lockTimestamp: number
lockDuration: number
createdAt: Date
updatedAt?: Date | null
}
export type AccountModelDocument = AccountModel & Document
export const Schema = new mongoose.Schema<AccountModelDocument>({
balance: {
type: Number,
description: 'Balance amount from account'
},
status: {
type: String,
values: [AccountStatus.ACTIVED, AccountStatus.PENDING],
description: 'Status from account'
},
locked: {
type: Boolean,
default: false,
description: 'Pessimistic lock for document'
},
lockTimestamp: {
type: Number
},
lockDuration: {
type: Number, // in milisseconds
default: 60 * 1000 // 1 minute
}
}, {
collection: 'Account',
timestamps: true
})
export const Account = mongoose.model<AccountModel & Document>(
'Account',
Schema
)
// transaction schema and model
import mongoose, { Schema, type Document, type Model } from 'mongoose'
export enum TransactionStatusEnum {
PENDING = 'PENDING',
PAID = 'PAID',
FAILED = 'FAILED'
}
export enum TransactionTypeEnum {
CREDIT = 'CREDIT',
DEBIT = 'DEBIT',
PIX = 'PIX'
}
export type TransactionModel = {
publicId: number
amount: number
originSenderAccountId: mongoose.Types.ObjectId
destinationReceiverAccountId: mongoose.Types.ObjectId
status: TransactionStatusEnum
type: TransactionTypeEnum
createdAt: Date
updatedAt: Date | null
idempotencyKey: string
}
export type TransactionModelDocument = TransactionModel & Document
const transactionSchema = new mongoose.Schema<TransactionModelDocument>(
{
publicId: Number,
amount: Number,
status: {
type: String,
values: [
TransactionStatusEnum.PENDING,
TransactionStatusEnum.FAILED,
TransactionStatusEnum.PAID
],
description: 'Status from transaction: "PENDING", "FAILED" or "SUCCESS"'
},
type: {
type: String,
values: [
TransactionTypeEnum.PIX,
TransactionTypeEnum.CREDIT,
TransactionTypeEnum.DEBIT
],
description: 'Type of operation "PIX", "CREDIT" or "DEBIT"'
},
destinationReceiverAccountId: {
type: Schema.Types.ObjectId,
description: 'Destination account that will receive the amount'
},
originSenderAccountId: {
type: Schema.Types.ObjectId,
description: 'Origin account that will send the amount sent'
},
idempotencyKey: {
type: String,
description: 'The unique Idempotency key to identify the transaction'
},
},
{
collection: 'Transaction',
timestamps: true
}
)
transactionSchema.index({
destinationReceiverAccountId: 1,
originSenderAccountId: 1,
idempotencyKey: 1,
amount: 1
})
export const Transaction: Model<TransactionModelDocument> = mongoose.model(
'Transaction',
transactionSchema
)
TransactionService.ts
const originAccount = await AccountModel.findOneAndUpdate({
publicId: originSenderAccountPublicId
}, {
$set: {
locked: true,
lockTimestamp: +new Date(),
lockDuration: 60 * 1000
}
}, {
new: false
})
const destinationAccount = await AccountModel.findOneAndUpdate({
publicId: destinationReceiverAccountPublicId
}, {
$set: {
locked: true,
lockTimestamp: +new Date(),
lockDuration: 60 * 1000
}
}, {
new: false
})
if (!originAccount || !destinationAccount) {
return {
error: 'Origin or destination account not found',
success: null,
transaction: null
}
}
if (originAccount.locked || destinationAccount.locked) {
const currentTime = +new Date()
if (
!(
currentTime >
originAccount.lockTimestamp + originAccount.lockDuration
) ||
!(
currentTime >
destinationAccount.lockTimestamp + destinationAccount.lockDuration
)
) {
return {
error: 'Origin or destination account are locked',
success: null,
transaction: null
}
}
}
const alreadyTransactionMake = await Transaction.findOne({
idempotencyKey
})
if (alreadyTransactionMake) {
const transaction = alreadyTransactionMakeCache
? JSON.parse(alreadyTransactionMakeCache)
: alreadyTransactionMake
return {
success: 'Transfer completed successfully',
cache: true,
error: null,
transaction: {
...transaction,
amount: -transaction.amount,
createdAt: new Date(transaction.createdAt).toISOString().slice(0, 10) // only date YYYY-MM-DD
}
}
}
if (originAccount.balance <= 0) {
return {
success: null,
cache: false,
error: 'Origin account can not transfer because the balance is zero',
transaction: null
}
}
const lockTimestamp = +new Date()
const orignAccount = await Account.findByIdAndUpdate(originAccount._id, {
$set: {
balance: originAccount.balance - amount,
locked: false,
lockTimestamp,
updatedAt: date
}
})
const destAccount = await Account.findByIdAndUpdate(destinationAccount._id, {
$set: {
balance: destinationAccount.balance + amount,
locked: false,
lockTimestamp,
updatedAt: date
}
})
const publicId = generateUniqueIntId()
const transaction = await Transaction.create({
publicId,
amount,
originSenderAccountId: originAccount.id,
destinationReceiverAccountId: destinationAccount.id,
status: 'PAID',
type: 'PIX',
idempotencyKey,
createdAt: new Date(),
updatedAt: null
})
ConsistencyAccountBalance.spec.ts
describe('Account Balance', () => {
it('should be consistency in the balance account while trying two request simultaneously', () => {
// create origin account with balance 1000
// create destination account with balance 1000
// make requests
const request1 = callRequest1({
originId: 1,
destinationId: 2,
transferAmount: 1
}) // asynchronous request
const request2 = callRequest1({
originId: 1,
destinationId: 2,
transferAmount: 2
}) // asynchronous request
console.log(await Promise.allSetled([request1, request2]))
// result:
/*
[
{
status: 'fullfilled',
value: {
data: {
success: 'Transfer successfully',
CreateTransaction: {
originId: 1,
destinationId: 2,
transferAmount: 1,
originBalance: 999,
destinationBalance: 1001,
}
}
}
},
{
status: 'fullfilled',
value: {
data: {
error: 'Origin or destination account are locked',
CreateTransaction: null
}
}
}
]
*/
const originAcc = await Account.findOne({ _id: 1 })
const destAcc = await Account.findOne({ _id: 2 })
expect(originAcc.balance).toBe(1000 - 1)
expect(destAcc.balance).toBe('Origin or destination account are locked')
})
})
Understand that I in the solution example was changed the method findOne
to findOneAndUpdate
(is responsible for atomicity) and I define three new properties for the account collection, are they:
- locked
- lockTimestamp
- lockDuration Each one with a one function specific: locked: Is used to lock the document record to other instances that don't have access to this document while It is modified lockTimestamp: This is used to store the exact moment that the document was locked lockDuration: Is used to save the time duration from lock
lockDuration and lockTimestamp ensures that the document will not be locked forever due to some error, timeout, or aborted request during the lock operation.
In the references, a GitHub link was added that gives you access to an open source project that implemented this approach.
Now with this solution, I can ensure that the document will be accessed by 1 process at a time because I already expect another process to be blocked from accessing the document during multiple transfers from the same account causing the consistency balance.
Pessimistic locks are very useful when we need to ensure that the document is not modified by other processes while it is already being accessed by one process.
We also saw how to handle it in cases the process died like some error not expected, timeout, or aborted request, using the lockDuration to prevent the document stay locked.
Bank Server
Concurrency Locks - MongoDB
Concurrency vs. Parallelism — A brief view
#pessimist_lock #post #mongoose #mongodb #nodejs #typescript