The correct implementation to the transfer problem will look like the following code (the full code sample is attached with the code bundle):
from pymongo import MongoClient
import json
class InitData:
def __init__(self):
self.client = MongoClient('localhost', 27017, w='majority')
self.db = self.client.mongo_bank
self.accounts = self.db.accounts
# drop data from accounts collection every time to start from a clean slate
self.accounts.drop()
init_data = InitData.load_data(self)
self.insert_data(init_data)
self.transfer('1', '2', 300)
@staticmethod
def load_data(self):
ret = []
with open('init_data.json', 'r') as f:
for line in f:
ret.append(json.loads(line))
return ret
def insert_data(self, data):
for document in data:
collection_name = document['collection']
account_id = document['account_id']
account_name = document['account_name']
account_balance = document['account_balance']
self.db[collection_name].insert_one({'account_id': account_id, 'name': account_name, 'balance': account_balance})
# validating errors, using the tx session
def tx_transfer_err_ses(self, source_account, target_account, value):
print(f'transferring {value} Hypnotons from {source_account} to {target_account}')
with self.client.start_session() as ses:
ses.start_transaction()
res = self.accounts.update_one({'account_id': source_account}, {'$inc': {'balance': value * (-1)}}, session=ses)
res2 = self.accounts.update_one({'account_id': target_account}, {'$inc': {'balance': value}}, session=ses)
error_tx = self.__validate_transfer_ses(source_account, target_account, ses)
if error_tx['status'] == True:
print(f"cant transfer {value} Hypnotons from {source_account} ({error_tx['s_bal']}) to {target_account} ({error_tx['t_bal']})")
ses.abort_transaction()
else:
ses.commit_transaction()
# we are passing the session value so that we can view the updated values
def __validate_transfer_ses(self, source_account, target_account, ses):
source_balance = self.accounts.find_one({'account_id': source_account}, session=ses)['balance']
target_balance = self.accounts.find_one({'account_id': target_account}, session=ses)['balance']
if source_balance < 0 or target_balance < 0:
return {'status': True, 's_bal': source_balance, 't_bal': target_balance}
else:
return {'status': False}
def main():
InitData()
if __name__ == '__main__':
main()
In this case, by passing the session object's ses value, we ensure that we can both make changes in our database using update_one() and also view these changes using find_one(), before doing either an abort_transaction() operation or a commit_transaction() operation.
Transactions cannot perform data definition language (DDL) operations, so drop(), create_collection(), and other operations that can affect MongoDB's DDL will fail inside a transaction. This is why we are setting w='majority' in our MongoClient object, to make sure that, when we drop a collection right before we start our transaction, this change will be visible to the transaction.
We need to make sure that the collection exists before we attempt to insert or upsert (update and insert) a document.
In the end, using transactions if we need to rollback, we don't need to keep track of the previous account balance values, as MongoDB will discard all of the changes that we made inside the transaction scope.
Continuing with the same example using Ruby, we have the following code for part 3:
require 'mongo'
class MongoBank
def initialize
@client = Mongo::Client.new([ '127.0.0.1:27017' ], database: :mongo_bank)
db = @client.database
@collection = db[:accounts]
# drop any existing data
@collection.drop
@collection.insert_one('collection': 'accounts', 'account_id': '1', 'account_name': 'Alex', 'account_balance':100)
@collection.insert_one('collection': 'accounts', 'account_id': '2', 'account_name': 'Mary', 'account_balance':50)
transfer('1', '2', 30)
transfer('1', '2', 300)
end
def transfer(source_account, target_account, value)
puts "transferring #{value} Hypnotons from #{source_account} to #{target_account}"
session = @client.start_session
session.start_transaction(read_concern: { level: :snapshot }, write_concern: { w: :majority })
@collection.update_one({ account_id: source_account }, { '$inc' => { account_balance: value*(-1)} }, session: session)
@collection.update_one({ account_id: target_account }, { '$inc' => { account_balance: value} }, session: session)
source_account_balance = @collection.find({ account_id: source_account }, session: session).first['account_balance']
if source_account_balance < 0
session.abort_transaction
else
session.commit_transaction
end
end
end
# initialize class
MongoBank.new
Alongside all of the points raised in the example in Python, we find that we can also customize read_concern and write_concern per transaction.
The available read_concern levels for multi-document ACID transactions are as follows:
- majority: A majority of the servers in a replica set have acknowledged the data. For this to work as expected in transactions, they must also use write_concern to majority.
- local: Only the local server has acknowledged the data.
- snapshot: The default read_concern levels for transactions as of MongoDB 4.0. If the transaction commits with majority as write_concern, all transaction operations will have read from a snapshot of majority committed data, otherwise no guarantee can be made.
The available write_concern levels for multi-document ACID transactions are the same as everywhere else in MongoDB, except for w:0 (no acknowledgement), which is not supported at all.