1

I'm writting an app that manages appointments to different services. Each service "capacity" is determined by one or more Timetables, meaning that service A may have 2 "desks" form June 1 to June 30 while having only 1 from July 1 to August 31, so I can create 2 appointments for '2020-06-03 9:00' but only 1 for '2020-07-03 9:00'. Everything is modeled just right and I have a custom validator for Appointments on create that checks the cardinality but that isn't enough to prevent two users creating the last available appointment at the same time is it?

How can I control the correct cardinality of this kind of relation without blocking the whole Appointments table?

Appointment creation is done in one place and one place only in the code, in Appointment.create_appointment(params) , is there a way to make that method locked in rails?

  • You're correct in that a Rails validation will not prevent duplicate data from being inserted if a race condition occurs. This can perhaps by solved by adding a constraint in the database. What RDBMS are you using? – max Jun 02 '20 at 10:09
  • postgresql. Right now i've got this solved with ActiveRecord::Base.connection.execute('LOCK TABLE appointments IN EXCLUSIVE MODE') but i feel that locking the whole table shouldn't be neccesary. – Carlos Matesanz Jun 02 '20 at 10:26
  • You're right. The solution is probally to write a (PLpgSQL) database function that that can be called from a constraint. I might be able to help you further if you provide an example of the schema and the desired input/output so that I can actually run something without filling in all the blanks with guesses. https://severalnines.com/database-blog/understanding-check-constraints-postgresql – max Jun 02 '20 at 10:36
  • that's very kind, max, but don't get into that trouble, I'd rather not use db functions as they often are a maintence pain. I was hoping for a rails-oriented solution for this. – Carlos Matesanz Jun 02 '20 at 10:42
  • Sorry but thats a fools errand since any application level (Rails) solution will still be prone to race conditions as you have multiple web processes communicating with the database server at once. This article explains it pretty well https://thoughtbot.com/blog/the-perils-of-uniqueness-validations – max Jun 02 '20 at 10:45
  • thank you, will read that asap. – Carlos Matesanz Jun 02 '20 at 11:21

1 Answers1

0

There are several ways to implement such restrictions, and the best is to let the database handle hard constrains.

Option one

Considering you have two models, Timetable and Appointment, add available_slots integer column to the Timetable model and decrease that number upon appointment creation and let the database raise an exception if that number goes below zero. In this case, Postgress will lock the column while updating it at the same time, preventing race conditions.

So Timetable could look like:

+----+--------------+--------------+-----------------+
| ID |  time_from   |   time_to    | available_slots |
+----+--------------+--------------+-----------------+
| 1  | '2020-03-21' | '2020-04-21' |               2 |
| 2  | '2020-04-22' | '2020-05-21' |               3 |
+----+--------------+--------------+-----------------+

In MySQL, you would make it an unsigned integer, but since Postgres doesn't support it, you have the option to add a positive number check constrain to the available_slots column:

Pure SQL:

ALTER TABLE timetables ADD CONSTRAINT available_slots CHECK (available_slots > 0)

A migration will look like:

class AddPositiveConstraintToTimetable < ActiveRecord::Migration[6.0]
  def self.up
    execute "ALTER TABLE timetables ADD CONSTRAINT available_slots CHECK (available_slots > 0)"
  end

  def self.down
    execute "ALTER TABLE timetables DROP CONSTRAINT available_slots."
  end
end

Add to Appointment model the logic that will decrease available_slots:

belongs_to :timetable
before_create :decrease_slots

def decrease_slots
  # this will through an exception from the database
  # in case if available_slots are already 0
  # that will prevent the instance from being created.
  
  timetable.decrement!(:available_slots)
end

Catch the exception from AppointmentsController:

def create
  @appointment = Appointment.new(appointment_params)

  # here will be probably some logic to find out the timetable
  # based on the time provided by the user (or maybe it's in the model).

  if @appointment.save
    redirect_to @appointment, notice: 'Appointment was successfully created.'
  else
    render :new
  end
end

Option two

Another way to do it is to add a new model, for example, AvailableSlot that will belong to Appointment and Timetable, and each record in the table will represent an available slot.

For example, if Timetable with id 1 will have three available slots, the table will look like:

Timetable.find(1).available_slots

+----+---------------+
| ID |  timetable_id |
+----+---------------+
| 1  | 1             |
| 2  | 1             |
| 3  | 1             |
+----+---------------+

Then add a unique index constrain to the available_slot_id column in the appointments table:

add_index :appointments, :available_slot_id, unique: true

So every time you create an appointment and associate it with an available slot, the database will, through an exception, if there is a record with the same available_slot_id.

You will have to add logic to find an available slot. A raw example in Appointment model:

before_create :find_available_slot

def find_available_slot
  # first find a timetable
  timetable = Timetable.where("time_from >= ? AND time_to <= ?", appointment_time, appointment_time).first
  
  # then check if there are available slots
  taken_slots = Appintment.where(timetable.id: timetable.id).size
  all_slots = timetable.available_slots.size

  raise "no available slots" unless (all_slots - taken_slots).positive?

  # huzzah! there are slots, lets take the last one
  self.available_slot = timetable.available_slots.last
end

That code can be simplified if you add a status column to available_slots that will be changed when an appointment is created, but I leave it to you to figure that out.


These options are based on similar approaches that I've seen on a production Rails applications with a lot of concurrent transactions going on (millions per day) that could cause raise conditions.

Community
  • 1
  • 1
Aleksander Lopez
  • 515
  • 4
  • 17