Creo que esto se puede lograr mediante el uso de una pequeña mesa doble elegante y algunas restricciones.
Comencemos por alguna estructura (no totalmente normalizada):
/* Everything goes to one schema... */
CREATE SCHEMA bookings ;
SET search_path = bookings ;
/* A table for theatre sessions (or events, or ...) */
CREATE TABLE sessions
(
session_id integer /* serial */ PRIMARY KEY,
session_theater TEXT NOT NULL, /* Should be normalized */
session_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
performance_name TEXT, /* Should be normalized */
UNIQUE (session_theater, session_timestamp) /* Alternate natural key */
) ;
/* And one for bookings */
CREATE TABLE bookings
(
session_id INTEGER NOT NULL REFERENCES sessions (session_id),
seat_number INTEGER NOT NULL /* REFERENCES ... */,
booker TEXT NULL,
PRIMARY KEY (session_id, seat_number),
UNIQUE (session_id, seat_number, booker) /* Needed redundance */
) ;
Las reservas de la mesa, en lugar de tener una is_booked
columna, tienen una booker
columna. Si es nulo, el asiento no está reservado, de lo contrario, este es el nombre (id) del reservante.
Agregamos algunos datos de ejemplo ...
-- Sample data
INSERT INTO sessions
(session_id, session_theater, session_timestamp, performance_name)
VALUES
(1, 'Her Majesty''s Theatre',
'2017-01-06 19:30 Europe/London', 'The Phantom of the Opera'),
(2, 'Her Majesty''s Theatre',
'2017-01-07 14:30 Europe/London', 'The Phantom of the Opera'),
(3, 'Her Majesty''s Theatre',
'2017-01-07 19:30 Europe/London', 'The Phantom of the Opera') ;
-- ALl sessions have 100 free seats
INSERT INTO bookings (session_id, seat_number)
SELECT
session_id, seat_number
FROM
generate_series(1, 3) AS x(session_id),
generate_series(1, 100) AS y(seat_number) ;
Creamos una segunda tabla para reservas, con una restricción:
CREATE TABLE bookings_with_bookers
(
session_id INTEGER NOT NULL,
seat_number INTEGER NOT NULL,
booker TEXT NOT NULL,
PRIMARY KEY (session_id, seat_number)
) ;
-- Restraint bookings_with_bookers: they must match bookings
ALTER TABLE bookings_with_bookers
ADD FOREIGN KEY (session_id, seat_number, booker)
REFERENCES bookings.bookings (session_id, seat_number, booker) MATCH FULL
ON UPDATE RESTRICT ON DELETE RESTRICT
DEFERRABLE INITIALLY DEFERRED;
Esta segunda tabla contendrá una COPIA de las tuplas (session_id, seat_number, booker), con una FOREIGN KEY
restricción; eso no permitirá que las reservas originales sean ACTUALIZADAS por otra tarea. [Suponiendo que nunca hay dos tareas relacionadas con el mismo booker ; si ese fuera el caso, se task_id
debería agregar una columna determinada .]
Siempre que necesitemos hacer una reserva, la secuencia de pasos seguidos dentro de la siguiente función muestra el camino:
CREATE or REPLACE FUNCTION book_session
(IN _booker text, IN _session_id integer, IN _number_of_seats integer)
RETURNS integer /* number of seats really booked */ AS
$BODY$
DECLARE
number_really_booked INTEGER ;
BEGIN
-- Choose a random sample of seats, assign them to the booker.
-- Take a list of free seats
WITH free_seats AS
(
SELECT
b.seat_number
FROM
bookings.bookings b
WHERE
b.session_id = _session_id
AND b.booker IS NULL
ORDER BY
random() /* In practice, you'd never do it */
LIMIT
_number_of_seats
FOR UPDATE /* We want to update those rows, and book them */
)
-- Update the 'bookings' table to have our _booker set in.
, update_bookings AS
(
UPDATE
bookings.bookings b
SET
booker = _booker
FROM
free_seats
WHERE
b.session_id = _session_id AND
b.seat_number = free_seats.seat_number
RETURNING
b.session_id, b.seat_number, b.booker
)
-- Insert all this information in our second table,
-- that acts as a 'lock'
, insert_into_bookings_with_bookers AS
(
INSERT INTO
bookings.bookings_with_bookers (session_id, seat_number, booker)
SELECT
update_bookings.session_id,
update_bookings.seat_number,
update_bookings.booker
FROM
update_bookings
RETURNING
bookings.bookings_with_bookers.seat_number
)
-- Count real number of seats booked, and return it
SELECT
count(seat_number)
INTO
number_really_booked
FROM
insert_into_bookings_with_bookers ;
RETURN number_really_booked ;
END ;
$BODY$
LANGUAGE plpgsql VOLATILE NOT LEAKPROOF STRICT
COST 10000 ;
Para realmente hacer una reserva, su programa debe intentar ejecutar algo como:
-- Whenever we wich to book 37 seats for session 2...
BEGIN TRANSACTION ;
SELECT
book_session('Andrew the Theater-goer', 2, 37) ;
/* Three things can happen:
- The select returns the wished number of seats
=> COMMIT
This can cause an EXCEPTION, and a need for (implicit)
ROLLBACK which should be handled and the process
retried a number of times
if no exception => the process is finished, you have your booking
- The select returns less than the wished number of seats
=> ROLLBACK and RETRY
we don't have enough seats, or some rows changed during function
execution
- (There can be a deadlock condition... that should be handled)
*/
COMMIT /* or ROLLBACK */ TRANSACTION ;
Esto se basa en dos hechos 1. La FOREIGN KEY
restricción no permitirá que se rompan los datos . 2. ACTUALIZAMOS la tabla de reservas, pero solo INSERTAMOS (y nunca ACTUALIZAMOS ) en la tabla bookings_with_bookers (la segunda tabla).
No necesita SERIALIZABLE
nivel de aislamiento, lo que simplificaría enormemente la lógica. En la práctica, sin embargo, se esperan callejones sin salida , y el programa que interactúa con la base de datos debe estar diseñado para manejarlos.