ActiveRecord pessimistic locking a record for reservation system - ruby-on-rails

I have a reservation system where I need to lock a record so that two users cannot book it at the same time.
The model Seat has three statuses: available, reserved and booked.
While being reserved the user has 5 minutes to complete the booking.
I can do this with a pessimistic locking. I add a lock_version column to the table and do
def reservation
#seat = Seat.available.order(created_at: :desc).first
if #seat
#seat.reserved!
redirect_to 'continue to confirm booking'
else
redirect_back with message "no seats available"
end
rescue ActiveRecord::StaleObjectError
retry
end
end
Since in this system the possibility of conflicts is very likely, I'd like to use a pessimistic locking intead of an optimistic one, but I didn't succeed.
This is the code I tried (note the addition of lock):
def reservation
#seat = Seat.available.order(created_at: :desc).lock.first # executes a SELECT FOR UPDATE
if #seat
#seat.reserved!
redirect_to 'continue to confirm booking'
else
redirect_back with message "no seats available"
end
end
The problem is that two records are still being selected at the same time even though the query is
SELECT * from seats where status = 'available' order by created_at desc limit 1 for update.
I am not sure how to implement an optimistic locking for such case.

After some hours of research I found out what was the issue.
Looking again at the optimistic code, the issue is that there's no transaction defined.
Adding a transaction fixes the problem. Here is the corrected code:
def reservation
Seat.transaction do
#seat = Seat.available.order(created_at: :desc).lock.first
if #seat
#seat.reserved!
redirect_to 'continue to confirm booking'
else
redirect_back with message "no seats available"
end
end
end
now the select for update and update of the record itself are within the same transaction.

Related

Modifying ActiveRecord models before preventing deletion

Some records in my application have a DOI assigned to them and in that case they should not be deleted. Instead, they should have their description changed and be flagged when a user triggers their deletion. A way to do this, I thought, would be as follows in the relevant model:
before_destroy :destroy_validation
private
def destroy_validation
if metadata['doi'].blank?
# Delete as normal...
nil
else
# This is a JSON field.
modified_metadata = Marshal.load(Marshal.dump(metadata))
description = "Record does not exist anymore: #{name}. The record with identifier content #{doi} was invalid."
modified_metadata['description'] = description
modified_metadata['tombstone'] = true
update_column :metadata, modified_metadata
raise ActiveRecord::RecordNotDestroyed, 'Records with DOIs cannot be deleted'
end
end
This does indeed prevent deletion, but the record appears unchanged afterwards rather than having a modified description. Here's an example of a test:
test "records with dois are not deleted" do
record = Record.new(metadata: metadata)
record.metadata['doi'] = 'this_is_a_doi'
assert record.save
assert_raises(ActiveRecord::RecordNotDestroyed) { record.destroy! }
assert Record.exists?(record.id)
modified_record = Record.find(record.id)
puts "#{record.description}" # This is correctly modified as per the callback code.
puts "#{modified_record.description}" # This is the same as when the record was created.
end
I can only guess that Rails is rolling back the update_column due to an exception having been raised, though I may be mistaken. Is there anything I can do to prevent this?
save and destroy are automatically wrapped in a transaction
https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
So destroy fails, transactions is rolled back and you can't see updated column in tests.
You could try with after_rollback callback https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#method-i-after_rollback
or do record.destroy check for record.errors, if found update record with method manually record.update_doi if record.errors.any?.
before_destroy :destroyable?
...
def destroyable?
unless metadata['doi'].blank?
errors.add('Doi is not empty.')
throw :abort
end
end
def update_doi
modified_metadata = Marshal.load(Marshal.dump(metadata))
description = "Record does not exist anymore: #{name}. The record with identifier content #{doi} was invalid."
modified_metadata['description'] = description
modified_metadata['tombstone'] = true
update_column :metadata, modified_metadata
end
Tip: use record.reload instead of Record.find(record.id).

Ruby on Ruby records redundant rows with same ids

I have been facing one serious issue in Ruby on Rails code for quite a long time. The active record records the transactions redundantly sometimes upto 3 times but this doesn't happen to all users. Let's say out of 100 upto 3 are affected. Surprisingly since i am using timestamp to save transaction ids all redundant rows often are recorded with the same id not even a difference of single second between all of them.
skip_before_action :verify_authenticity_token
before_action :restrict_access, except: [:authenticate, :go_success, :go_failed]
def creategoal
goal = #user_info.goal.create(days: 365)
if goal.save
goal.goals_mandates.create(reference: "000-000-GG-#{goal.id}",
price_dd: params[:amount],
mandate_attempt_date: Date.today,
mandate_active: false)
self.initTrans(goal)
end
end
def initTrans(goal)
userType = UserType.where(name: "partner").first
if userType.user_info_types.where(user_info_id: #user.id).first.nil?
#user.user_info_types.create(user_type_id: userType.id)
end
transaction_id = "GG-"+ Time.now.to_i.to_s
transaction = goal.goal_transaction.create(batch_id: transaction_id,
response_batch: transaction_id,
amount_deducted: session[:amount_to_save],
transaction_type: "direct_debit",
merchant: #accessor.name)
if transaction.save
redirect_to "/success?trans_id="+transaction_id
else
redirect_to "/failed"
end
end
I will be grateful if anyone genius out there can help me out.

Setting limit to record entries

I have the following:
after_action :prevent_order_create, :only => [:update, :create]
...
private
def prevent_order_create
#order = Order.find(params[:id]) #line 270
#listing = Order.find_by(params[:listing_id])
#order_seller = #order.where(order_status: [1]).where(:seller)
if #order_seller.count >= 0
#listing.update_column(:listing_status, 3)
end
end
The goal is to limit the amount of orders that can be open for any 1 seller.
When I use this code, I get the following error:
ActiveRecord::RecordNotFound (Couldn't find Order without an ID):
app/controllers/orders_controller.rb:270:in `prevent_order_create'
The order gets created but for whatever reason this issue is happening. Shouldn't the method be getting checked AFTER the order is made without issues?
btw using 0 for testing purposes.
EDIT:
I did:
def prevent_order_create
#order_seller = Order.where(order_status: [1]).where(listing_id: Listing.ids)
if #order_seller.count >= 10
#listing.update_column(:listing_status, 3)
end
end
Seems to work for now. Will update.
Everything you describe is as expected.
An after action is run after the action is already executed, so the record has already been persisted to your DB by the time the exception happens.
The exception is caused because params[:id] is nil, which makes sense for a create action.
Update after clarification in comment
OP said:
I want after create and update... to find any orders with matching :listing_id's and with order_status's of [1] to then count those records. If the count is >= 1000, to then change the listing_status of the Listing
I think the way I would do this is in an after_save in the Order model. Based on your latest edit something like:
scope :status_one, -> { where order_status: 1 } # I'm guessing this should be 1 not [1] as you keep putting
after_save :update_listing
def update_listing
if listing.orders.status_one.count >= 10
listing.update! listing_status: 3
end
end
So that's basically saying that if the associated listing has 10 or more associated orders with order_status=1 then set its status to 3.

How find the distance between two objects?

Im using geocode. The idea is our partners can post products with an address. When they do so it fetches the latitude and longitude. Now when our customers go to buy that product they have to enter in a delivery address to tell us where to deliver the product. However if they delivery address is not within 20 miles of the product they are not allowed to get the product delivered.
Im getting an error message saying this "undefined method `latitude' for nil:NilClass"
Like I said the product.longitude, product.latitude is already set when the users are trying to order.
Not sure if it's because the order.delivery_address(lat, long) is not submitted into the database yet and its trying to check the distance. Here my code below
So My question is how can is how can i find the distance between the product address and order address and I want to show a alert message to the user if the distance between the two is over 20 miles.
def create
product = Product.find(params[:product_id])
if current_user == product.user
flash[:alert] = "You cannot purchase your own property"
elsif current_user.stripe_id.blank? || current_user.phone_number.blank?
flash[:alert] = " Please update your payment method and verify phone number please"
return redirect_to payment_method_path
elsif Geocoder::Calculations.distance_between([product.latitude, product.longitude], [#order.latitude, #order.longitude]) < 20
flash[:alert] = "The delivery address you provided is outside the delivery zone. Please choose a different product."
else
quantity = order_params[:quantity].to_i
#order = current_user.orders.build(order_params)
#order.product = product
#order.price = product.price
#order.total = product.price * quantity + product.delivery_price
# #order.save
if #order.Waiting!
if product.Request?
flash[:notice] = "Request sent successfully... Sit back and relax while our licensed dispensary fulfil your order :)"
else
#order.Approved!
flash[:notice] = "Your order is being filled and it will delivered shortly:)"
end
else
flash[:alert] = "Our licensed dispensary cannot fulfil your order at this time :( "
end
end
redirect_to product
end
You set #order in the following line:
#order = current_user.orders.build(order_params)
But you try to call its longitude and latitude methods above this, before you even set #order variable. To simply fix this problem, you can move this line up, it can even be located at the beginning of create method, since it doesn't depend on product or anything like that:
def create
#order = current_user.orders.build(order_params)
# ...
end
Although, there are number of problems in your code, like method names starting with capital letters (you can do it, but you shouldn't, it's against the convention) or overall complexity of the method.
You should move the business logic to the model where it belongs.
So lets start by creating a validation for the product distance:
class Order < ApplicationRecord
validates :product_is_within_range,
if: -> { product.present? } # prevents nil errors
# our custom validation method
def product_is_within_range
errors.add(:base, "The delivery address you provided is outside the delivery zone. Please choose a different product.") if product_distance < 20
end
def product_distance
Geocoder::Calculations.distance_between(product.coordinates, self.coordinates)
end
end
Then move the calculation of the total into the model:
class Order < ApplicationRecord
before_validation :calculate_total!, if: ->{ product && total.nil? }
def calculate_total!
self.total = product.price * self.quantity + product.delivery_price
end
end
But then you still have to deal with the fact that the controller is very broken. For example:
if current_user == product.user
flash[:alert] = "You cannot purchase your own property"
Should cause the method to bail. You´re not actually saving the record either. I would start over. Write failing tests for the different possible conditions (invalid parameters, valid parameters, user is owner etc) then write your controller code. Make sure you test each and every code branch.

Rails - how to set default values in a model for quantity

I'm trying to allow a User to book events for more than one space at a time, so if one space at an event costs £10 and a User wants to book four spaces then they would need to pay £40.
I've implemented a method in my Booking model to cater for this -
Booking.rb
class Booking < ActiveRecord::Base
belongs_to :event
belongs_to :user
def reserve
# Don't process this booking if it isn't valid
return unless valid?
# We can always set this, even for free events because their price will be 0.
self.total_amount = quantity * event.price_pennies
# Free events don't need to do anything special
if event.is_free?
save
# Paid events should charge the customer's card
else
begin
charge = Stripe::Charge.create(amount: total_amount, currency: "gbp", card: #booking.stripe_token, description: "Booking number #{#booking.id}", items: [{quantity: #booking.quantity}])
self.stripe_charge_id = charge.id
save
rescue Stripe::CardError => e
errors.add(:base, e.message)
false
end
end
end
end
When I try to process a booking I get the following error -
NoMethodError in BookingsController#create
undefined method `*' for nil:NilClass
This line of code is being highlighted -
self.total_amount = quantity * event.price_pennies
I need to check/make sure that quantity returns a value of 1 or more and event.price_pennies returns 0 if it is a free event and greater than 0 if it is a paid event. How do I do this?
I did not set any default values for quantity in my migrations. My schema.rb file shows this for price_pennies -
t.integer "price_pennies", default: 0, null: false
This is whats in my controller for create -
bookings_controller.rb
def create
# actually process the booking
#event = Event.find(params[:event_id])
#booking = #event.bookings.new(booking_params)
#booking.user = current_user
if #booking.reserve
flash[:success] = "Your place on our event has been booked"
redirect_to event_path(#event)
else
flash[:error] = "Booking unsuccessful"
render "new"
end
end
So, do I need a method in my booking model to rectify this or should I do a validation for quantity and a before_save callback for event?
I'm not quite sure how to do this so any assistance would be appreciated.
Just cast to integer, in this case you seem to be done:
self.total_amount = quantity.to_i * event.price_pennies.to_i
Migrations are used to modify the structure of you DB and not the data.
In your case I think you need to seed the DB with default values, and for that purpose you use 'db/seeds.rb' file which is invoked once every time your application is deployed.
You would do something like this in seeds.rb
Booking.find_or_create_by_name('my_booking', quantity:1)
So when the application is deployed the above line of code is executed. If 'my_booking' exists in the table then nothing happens, else it will create a new record with name='my_booking' and quantity=1.
In your localhost you'll execute 'rake db:seed' to seed the DB.

Resources