Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save HallexCosta/357c0e13c3a7aa032f49aec4c7f8fa80 to your computer and use it in GitHub Desktop.
Save HallexCosta/357c0e13c3a7aa032f49aec4c7f8fa80 to your computer and use it in GitHub Desktop.
Pessimistic Lock with Atomicity: A Pragmatic Approach

Pessimistic Lock with Atomicity: A Pragmatic Approach

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! 😃

Summary

  • 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.

Concurrency vs Parallelism

Quick Explanation

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.

Conventional code

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:

Non-atomic
image

Atomic
image

Solution codes

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.

Conclusion

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.

References

Bank Server
Concurrency Locks - MongoDB
Concurrency vs. Parallelism — A brief view

#pessimist_lock #post #mongoose #mongodb #nodejs #typescript

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment