Hotel reservations
Problem
One room, two confirmations
A small hotel has four rooms. Reservations come from everywhere: from the website, from the front desk, from travel agencies. One Friday evening, two agencies reserve the last double room in the same second. Both get a confirmation. On Saturday, two families arrive at the room.
We will build a system where the server keeps the rooms and the reservations, and clients send requests over the network. A client can:
- see the free rooms;
- check one room;
- reserve a room in their own name;
- cancel their own reservation.
And when two clients reserve the same room at the same time, only one of them succeeds. We will prove it with a program that gives the same output on every run.
We build from the bottom up: the model without a network, the rules and the errors, the protocol, the server and the client, the simultaneous requests, and finally everything in one program.
More precisely, if you are curious
node:net module). But there is only one thread: Node.js serves all clients in it and switches from one to another while they wait for the network. There is no lock. What guards the rooms then, we will see toward the end.The idea
The room
A room has a number, a type, a price per night and a state: free or taken.
class Room { private final int number; private final String type; private final int price; private boolean free = true;
public Room(int number, String type, int price) { this.number = number; this.type = type; this.price = price; }
public int getNumber() { return number; }
public String getType() { return type; }
public int getPrice() { return price; }
public boolean isFree() { return free; }
public void occupy() { free = false; }
public void release() { free = true; }
@Override public String toString() { return number + ", " + type + ", €" + price + " per night, " + (free ? "free" : "taken"); }}public class Room{ public int Number { get; } public string Type { get; } public int Price { get; } public bool IsFree { get; set; } = true;
public Room(int number, string type, int price) { Number = number; Type = type; Price = price; }
public void Occupy() { IsFree = false; }
public void Release() { IsFree = true; }
public override string ToString() { return Number + ", " + Type + ", €" + Price + " per night, " + (IsFree ? "free" : "taken"); }}class Room: def __init__(self, number, kind, price): self._number = number self._kind = kind self._price = price self._free = True
@property def number(self): return self._number
@property def kind(self): return self._kind
@property def price(self): return self._price
@property def is_free(self): return self._free
def occupy(self): self._free = False
def release(self): self._free = True
def __str__(self): state = "free" if self._free else "taken" return f"{self._number}, {self._kind}, €{self._price} per night, {state}"class Room { #number; #type; #price; #free = true;
constructor(number, type, price) { this.#number = number; this.#type = type; this.#price = price; }
get number() { return this.#number; }
get type() { return this.#type; }
get price() { return this.#price; }
get isFree() { return this.#free; }
occupy() { this.#free = false; }
release() { this.#free = true; }
toString() { const state = this.#free ? "free" : "taken"; return `${this.#number}, ${this.#type}, €${this.#price} per night, ${state}`; }}- The number, the type and the price do not change. Only the state changes, and only through two methods with clear names:
occupyandrelease. - The room does not know who is staying in it. The room goes out to every client who asks, and the guest's name should not leave the hotel.
More precisely, if you are curious
# are private: from outside they are not visible at all. The number, the type and the price are read through a getter without parentheses (room.number), and changing one from outside (room.number = 5) in a file with import stops with TypeError, because the getter has no setter. JavaScript has no enumerations. For more than two states you use a frozen object with their names, like Protocol below.See it
The hotel and the reservation
Map "room number → name":private final List<Room> rooms = new ArrayList<>();private final Map<Integer, String> guests = new HashMap<>();private readonly List<Room> rooms = new List<Room>();private readonly Dictionary<int, string> guests = new Dictionary<int, string>();def __init__(self): self._rooms = [] self._guests = {}#rooms = [];#guests = new Map();public void add(Room room) { rooms.add(room);}
public List<Room> freeRooms() { return rooms.stream().filter(Room::isFree).toList();}
public Room check(int number) { return find(number);}public void Add(Room room){ rooms.Add(room);}
public List<Room> FreeRooms(){ return rooms.Where(room => room.IsFree).ToList();}
public Room Check(int number){ return Find(number);}def add(self, room): self._rooms.append(room)
def free_rooms(self): return [room for room in self._rooms if room.is_free]
def check(self, number): return self._find(number)add(room) { this.#rooms.push(room);}
freeRooms() { return this.#rooms.filter((room) => room.isFree);}
check(number) { return this.#find(number);}#find is private: it looks up the room by number or throws an exception. That way nobody from outside can grab a room and take it around the rules:
private Room find(int number) { for (Room room : rooms) { if (room.getNumber() == number) { return room; } } throw new RoomNotFoundException("There is no room " + number + ".");}private Room Find(int number){ Room room = rooms.FirstOrDefault(r => r.Number == number); if (room == null) { throw new RoomNotFoundException("There is no room " + number + "."); } return room;}def _find(self, number): for room in self._rooms: if room.number == number: return room raise RoomNotFoundError(f"There is no room {number}.")#find(number) { const room = this.#rooms.find((r) => r.number === number); if (room === undefined) { throw new RoomNotFoundError(`There is no room ${number}.`); } return room;}The reservation checks the name, finds the room and checks whether it is free. Only then does it take it:
public void reserve(int number, String guest) { if (guest.isBlank()) { throw new HotelException("The guest's name is required."); } Room room = find(number); if (!room.isFree()) { throw new RoomTakenException("Room " + number + " is already taken."); } room.occupy(); guests.put(number, guest);}public void Reserve(int number, string guest){ if (string.IsNullOrWhiteSpace(guest)) { throw new HotelException("The guest's name is required."); } Room room = Find(number); if (!room.IsFree) { throw new RoomTakenException("Room " + number + " is already taken."); } room.Occupy(); guests[number] = guest;}def reserve(self, number, guest): if not guest.strip(): raise HotelError("The guest's name is required.") room = self._find(number) if not room.is_free: raise RoomTakenError(f"Room {number} is already taken.") room.occupy() self._guests[number] = guestreserve(number, guest) { if (guest.trim() === "") { throw new HotelError("The guest's name is required."); } const room = this.#find(number); if (!room.isFree) { throw new RoomTakenError(`Room ${number} is already taken.`); } room.occupy(); this.#guests.set(number, guest);}Hotel hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));
for (Room room : hotel.freeRooms()) { System.out.println(room);}hotel.reserve(102, "Anna Petrova");System.out.println("After the reservation: " + hotel.check(102));System.out.println("Free rooms: " + hotel.freeRooms().size());101, single, €60 per night, free102, double, €85 per night, free201, double, €95 per night, free202, suite, €140 per night, freeAfter the reservation: 102, double, €85 per night, takenFree rooms: 3Hotel hotel = new Hotel();hotel.Add(new Room(101, "single", 60));hotel.Add(new Room(102, "double", 85));hotel.Add(new Room(201, "double", 95));hotel.Add(new Room(202, "suite", 140));
foreach (Room room in hotel.FreeRooms()){ Console.WriteLine(room);}hotel.Reserve(102, "Anna Petrova");Console.WriteLine("After the reservation: " + hotel.Check(102));Console.WriteLine("Free rooms: " + hotel.FreeRooms().Count);101, single, €60 per night, free102, double, €85 per night, free201, double, €95 per night, free202, suite, €140 per night, freeAfter the reservation: 102, double, €85 per night, takenFree rooms: 3hotel = Hotel()hotel.add(Room(101, "single", 60))hotel.add(Room(102, "double", 85))hotel.add(Room(201, "double", 95))hotel.add(Room(202, "suite", 140))
for room in hotel.free_rooms(): print(room)hotel.reserve(102, "Anna Petrova")print(f"After the reservation: {hotel.check(102)}")print(f"Free rooms: {len(hotel.free_rooms())}")101, single, €60 per night, free102, double, €85 per night, free201, double, €95 per night, free202, suite, €140 per night, freeAfter the reservation: 102, double, €85 per night, takenFree rooms: 3const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));
for (const room of hotel.freeRooms()) { console.log(String(room));}hotel.reserve(102, "Anna Petrova");console.log(`After the reservation: ${hotel.check(102)}`);console.log(`Free rooms: ${hotel.freeRooms().length}`);101, single, €60 per night, free102, double, €85 per night, free201, double, €95 per night, free202, suite, €140 per night, freeAfter the reservation: 102, double, €85 per night, takenFree rooms: 3String(room) and the template string call toString(). console.log(room) does not call it: it shows the object's fields, the private ones are not visible, and it prints only Room {}.The idea
Canceling and the rules
Not every request can be carried out: the room is taken, there is no room 305, a guest cancels someone else's reservation. These are refusals by the rules, so they are your own exceptions with a common parent:
class HotelException extends RuntimeException { public HotelException(String message) { super(message); }}
class RoomNotFoundException extends HotelException { public RoomNotFoundException(String message) { super(message); }}
class RoomTakenException extends HotelException { public RoomTakenException(String message) { super(message); }}public class HotelException : Exception{ public HotelException(string message) : base(message) { }}
public class RoomNotFoundException : HotelException{ public RoomNotFoundException(string message) : base(message) { }}
public class RoomTakenException : HotelException{ public RoomTakenException(string message) : base(message) { }}class HotelError(Exception): pass
class RoomNotFoundError(HotelError): pass
class RoomTakenError(HotelError): passclass HotelError extends Error { constructor(message) { super(message); this.name = "HotelError"; }}
class RoomNotFoundError extends HotelError { constructor(message) { super(message); this.name = "RoomNotFoundError"; }}
class RoomTakenError extends HotelError { constructor(message) { super(message); this.name = "RoomTakenError"; }}Canceling checks that the room is taken and that the same guest is canceling it:
public void cancel(int number, String guest) { Room room = find(number); if (room.isFree()) { throw new HotelException("Room " + number + " is not reserved."); } if (!guests.get(number).equals(guest)) { throw new HotelException("Room " + number + " is reserved by another guest."); } room.release(); guests.remove(number);}public void Cancel(int number, string guest){ Room room = Find(number); if (room.IsFree) { throw new HotelException("Room " + number + " is not reserved."); } if (guests[number] != guest) { throw new HotelException("Room " + number + " is reserved by another guest."); } room.Release(); guests.Remove(number);}def cancel(self, number, guest): room = self._find(number) if room.is_free: raise HotelError(f"Room {number} is not reserved.") if self._guests[number] != guest: raise HotelError(f"Room {number} is reserved by another guest.") room.release() del self._guests[number]cancel(number, guest) { const room = this.#find(number); if (room.isFree) { throw new HotelError(`Room ${number} is not reserved.`); } if (this.#guests.get(number) !== guest) { throw new HotelError(`Room ${number} is reserved by another guest.`); } room.release(); this.#guests.delete(number);}The project brief asks for the system to be checked in different scenarios: reserving, checking and canceling, both when they are valid and when they are not. Each scenario is a line in the output:
Hotel hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));
hotel.reserve(102, "Anna Petrova");System.out.println("1. Free room: " + hotel.check(102));try { hotel.reserve(102, "Boris Iliev");} catch (RoomTakenException e) { System.out.println("2. Taken room: " + e.getMessage());}try { hotel.cancel(102, "Boris Iliev");} catch (HotelException e) { System.out.println("3. Someone else's reservation: " + e.getMessage());}hotel.cancel(102, "Anna Petrova");System.out.println("4. Cancellation: " + hotel.check(102));try { hotel.cancel(102, "Anna Petrova");} catch (HotelException e) { System.out.println("5. Second cancellation: " + e.getMessage());}try { hotel.reserve(305, "Anna Petrova");} catch (RoomNotFoundException e) { System.out.println("6. No such room: " + e.getMessage());}try { hotel.reserve(201, " ");} catch (HotelException e) { System.out.println("7. No name: " + e.getMessage());}1. Free room: 102, double, €85 per night, taken2. Taken room: Room 102 is already taken.3. Someone else's reservation: Room 102 is reserved by another guest.4. Cancellation: 102, double, €85 per night, free5. Second cancellation: Room 102 is not reserved.6. No such room: There is no room 305.7. No name: The guest's name is required.Hotel hotel = new Hotel();hotel.Add(new Room(101, "single", 60));hotel.Add(new Room(102, "double", 85));hotel.Add(new Room(201, "double", 95));hotel.Add(new Room(202, "suite", 140));
hotel.Reserve(102, "Anna Petrova");Console.WriteLine("1. Free room: " + hotel.Check(102));try{ hotel.Reserve(102, "Boris Iliev");}catch (RoomTakenException e){ Console.WriteLine("2. Taken room: " + e.Message);}try{ hotel.Cancel(102, "Boris Iliev");}catch (HotelException e){ Console.WriteLine("3. Someone else's reservation: " + e.Message);}hotel.Cancel(102, "Anna Petrova");Console.WriteLine("4. Cancellation: " + hotel.Check(102));try{ hotel.Cancel(102, "Anna Petrova");}catch (HotelException e){ Console.WriteLine("5. Second cancellation: " + e.Message);}try{ hotel.Reserve(305, "Anna Petrova");}catch (RoomNotFoundException e){ Console.WriteLine("6. No such room: " + e.Message);}try{ hotel.Reserve(201, " ");}catch (HotelException e){ Console.WriteLine("7. No name: " + e.Message);}1. Free room: 102, double, €85 per night, taken2. Taken room: Room 102 is already taken.3. Someone else's reservation: Room 102 is reserved by another guest.4. Cancellation: 102, double, €85 per night, free5. Second cancellation: Room 102 is not reserved.6. No such room: There is no room 305.7. No name: The guest's name is required.hotel = Hotel()hotel.add(Room(101, "single", 60))hotel.add(Room(102, "double", 85))hotel.add(Room(201, "double", 95))hotel.add(Room(202, "suite", 140))
hotel.reserve(102, "Anna Petrova")print(f"1. Free room: {hotel.check(102)}")try: hotel.reserve(102, "Boris Iliev")except RoomTakenError as e: print(f"2. Taken room: {e}")try: hotel.cancel(102, "Boris Iliev")except HotelError as e: print(f"3. Someone else's reservation: {e}")hotel.cancel(102, "Anna Petrova")print(f"4. Cancellation: {hotel.check(102)}")try: hotel.cancel(102, "Anna Petrova")except HotelError as e: print(f"5. Second cancellation: {e}")try: hotel.reserve(305, "Anna Petrova")except RoomNotFoundError as e: print(f"6. No such room: {e}")try: hotel.reserve(201, " ")except HotelError as e: print(f"7. No name: {e}")1. Free room: 102, double, €85 per night, taken2. Taken room: Room 102 is already taken.3. Someone else's reservation: Room 102 is reserved by another guest.4. Cancellation: 102, double, €85 per night, free5. Second cancellation: Room 102 is not reserved.6. No such room: There is no room 305.7. No name: The guest's name is required.const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));
hotel.reserve(102, "Anna Petrova");console.log(`1. Free room: ${hotel.check(102)}`);try { hotel.reserve(102, "Boris Iliev");} catch (e) { if (!(e instanceof RoomTakenError)) throw e; console.log(`2. Taken room: ${e.message}`);}try { hotel.cancel(102, "Boris Iliev");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`3. Someone else's reservation: ${e.message}`);}hotel.cancel(102, "Anna Petrova");console.log(`4. Cancellation: ${hotel.check(102)}`);try { hotel.cancel(102, "Anna Petrova");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`5. Second cancellation: ${e.message}`);}try { hotel.reserve(305, "Anna Petrova");} catch (e) { if (!(e instanceof RoomNotFoundError)) throw e; console.log(`6. No such room: ${e.message}`);}try { hotel.reserve(201, " ");} catch (e) { if (!(e instanceof HotelError)) throw e; console.log(`7. No name: ${e.message}`);}1. Free room: 102, double, €85 per night, taken2. Taken room: Room 102 is already taken.3. Someone else's reservation: Room 102 is reserved by another guest.4. Cancellation: 102, double, €85 per night, free5. Second cancellation: Room 102 is not reserved.6. No such room: There is no room 305.7. No name: The guest's name is required.The specific exception says what the problem is. HotelError catches all refusals at once: the server will catch only that one and turn it into a reply for the client.
catch in JavaScript has no type and catches everything. So every catch asks with instanceof whether the error is its own, and throws anything else on with throw e.More precisely, if you are curious
- There are no checked exceptions: no method declares what it throws. So the reception will catch them in one place.
- Scenarios like these are natural tests. With
node:testeach one becomes atest(...), andassert.throws(() => hotel.reserve(102, "Boris Iliev"), RoomTakenError)checks the exception.
Try it
Who is in 201
What will the program print?
const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));
hotel.reserve(101, "Anna Petrova");hotel.reserve(201, "Boris Iliev");hotel.cancel(101, "Anna Petrova");try { hotel.reserve(201, "Vera Koleva"); console.log("Vera is in 201");} catch (e) { if (!(e instanceof RoomTakenError)) throw e; console.log("Vera is turned away");}console.log(hotel.freeRooms().length);Anna takes 101 and then frees it. Boris takes 201, so Vera gets RoomTakenError, and the line "Vera is in 201" does not run. Only 201 is taken, so three rooms are free.
The idea
The protocol: a "COMMAND:text" line
Every message is one line COMMAND:text, and the server answers with one line:
| The client sends | The server answers |
|---|---|
FREE |
OK: and the free rooms, separated by ; |
CHECK:102 |
OK:102, double, €85 per night, taken |
RESERVE:102;Anna Petrova |
OK:Room 102 is yours, Anna Petrova. or ERROR: and the reason |
CANCEL:102;Anna Petrova |
OK:The reservation for room 102 is canceled. or ERROR: and the reason |
EXIT |
BYE, and the server closes the connection |
The words of the protocol are constants in one place (Object.freeze keeps the object from being changed):
const Protocol = Object.freeze({ FREE: "FREE", CHECK: "CHECK", RESERVE: "RESERVE", CANCEL: "CANCEL", EXIT: "EXIT",
OK: "OK", ERROR: "ERROR", BYE: "BYE",});The reception translates a protocol line into a call to the hotel and back:
class Reception { #hotel;
constructor(hotel) { this.#hotel = hotel; }
...}handle(line) { const colon = line.indexOf(":"); const command = colon < 0 ? line : line.slice(0, colon); const text = colon < 0 ? "" : line.slice(colon + 1); try { switch (command) { case Protocol.FREE: return `${Protocol.OK}:${this.#join(this.#hotel.freeRooms())}`; case Protocol.CHECK: return `${Protocol.OK}:${this.#hotel.check(this.#number(text))}`; case Protocol.RESERVE: return this.#reserve(text); case Protocol.CANCEL: return this.#cancel(text); default: throw new HotelError(`Unknown command ${command}.`); } } catch (e) { if (!(e instanceof HotelError)) throw e; return `${Protocol.ERROR}:${e.message}`; }}#reserve(text) { const [numberText, guest] = this.#numberAndGuest(text); const number = this.#number(numberText); this.#hotel.reserve(number, guest); return `${Protocol.OK}:Room ${number} is yours, ${guest}.`;}
#cancel(text) { const [numberText, guest] = this.#numberAndGuest(text); const number = this.#number(numberText); this.#hotel.cancel(number, guest); return `${Protocol.OK}:The reservation for room ${number} is canceled.`;}
#numberAndGuest(text) { const semicolon = text.indexOf(";"); if (semicolon < 0) { throw new HotelError("Write a number and a name, for example '102;Anna Petrova'."); } return [text.slice(0, semicolon), text.slice(semicolon + 1)];}
#number(text) { const number = Number(text); if (text.trim() === "" || !Number.isInteger(number)) { throw new HotelError("The room number must be a whole number."); } return number;}
#join(rooms) { if (rooms.length === 0) { return "There are no free rooms."; } return rooms.join("; ");}catch asks whether the error is a HotelError. If it is not, that is a bug in the code and not a refusal by the rules, and it goes on up. Number(text) does not throw: for "one hundred two" it gives NaN, and for empty text 0. That is why #number checks by itself that the text is not empty and the number is whole.The reception remembers nothing about the client: every line carries everything it needs, even the guest's name. That is why one reception is enough for all clients. You can check it without a network:
const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));
const reception = new Reception(hotel);const lines = [ "RESERVE:102;Anna Petrova", "CHECK:102", "RESERVE:102;Boris Iliev", "CANCEL:102", "CHECK:one hundred two", "SHOW",];for (const line of lines) { console.log(`> ${line}`); console.log(reception.handle(line));}> RESERVE:102;Anna PetrovaOK:Room 102 is yours, Anna Petrova.> CHECK:102OK:102, double, €85 per night, taken> RESERVE:102;Boris IlievERROR:Room 102 is already taken.> CANCEL:102ERROR:Write a number and a name, for example '102;Anna Petrova'.> CHECK:one hundred twoERROR:The room number must be a whole number.> SHOWERROR:Unknown command SHOW.More precisely, if you are curious
-
JavaScript has no enumerations. A frozen object is the closest thing: a closed list of names that nobody can change. In a file with
import, the attemptProtocol.FREE = "ALL"stops withTypeError, andProtocol.FREEstays"FREE". - The protocol is stateless: every line stands on its own. That makes it easy to check and easy to serve more clients. If the client first had to say "I am Anna", there would have to be one reception per client, like the session in the car dealership.
See it
The server
There is no thread pool. createServer calls its function for each new client, and that client's ClientHandler reads its lines with for await. While the client is silent, the thread serves the others: await does not stop it, it says "continue from here when it is your turn".
class ClientHandler { #socket; #reception;
constructor(socket, reception) { this.#socket = socket; this.#reception = reception; }
async run() { try { for await (const line of createInterface({ input: this.#socket })) { if (line === Protocol.EXIT) { break; } this.#socket.write(`${this.#reception.handle(line)}\n`); } this.#socket.end(`${Protocol.BYE}\n`); } catch (e) { console.log(`The connection to the client was lost: ${e.message}`); } }}HotelServer starts the server on the port. listen and close do not happen right away, so start and stop return a Promise and are called with await:
class HotelServer { #server; #port;
constructor(port, hotel) { const reception = new Reception(hotel); this.#port = port; this.#server = createServer((socket) => new ClientHandler(socket, reception).run()); }
start() { return new Promise((resolve) => this.#server.listen(this.#port, "localhost", resolve)); }
stop() { return new Promise((resolve) => this.#server.close(resolve)); }}- However many clients there are, one thread serves them all. It does not wait for the network: it does other work until the line arrives.
- All clients call the same
Hotel. So the hotel will have to guard its rooms. How, in a moment.
See it
The client
await. The lines from the socket are read with the same createInterface we use to read from the keyboard: next() gives the next line as soon as it arrives. Without text, send sends only the command. close() says EXIT and closes the connection:class HotelClient { #socket; #lines;
constructor(host, port) { this.#socket = createConnection({ host, port }); this.#lines = createInterface({ input: this.#socket })[Symbol.asyncIterator](); }
async send(command, text) { const line = text === undefined ? command : `${command}:${text}`; this.#socket.write(`${line}\n`); return (await this.#lines.next()).value; }
async close() { await this.send(Protocol.EXIT); this.#socket.end(); }}The server and the client in one program:
const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));const server = new HotelServer(5331, hotel);await server.start();
const client = new HotelClient("localhost", 5331);console.log(await client.send(Protocol.RESERVE, "102;Anna Petrova"));console.log(await client.send(Protocol.CHECK, "102"));console.log(await client.send(Protocol.FREE));console.log(await client.send(Protocol.CANCEL, "102;Anna Petrova"));console.log(await client.send(Protocol.CHECK, "305"));await client.close();await server.stop();OK:Room 102 is yours, Anna Petrova.OK:102, double, €85 per night, takenOK:101, single, €60 per night, free; 201, double, €95 per night, free; 202, suite, €140 per night, freeOK:The reservation for room 102 is canceled.ERROR:There is no room 305.The idea
Two for one room
await) while another one runs, this can happen:| Agency A | Agency B | |
|---|---|---|
| 1 | checks: 102 is free | |
| 2 | checks: 102 is free | |
| 3 | takes 102 for its guest | |
| 4 | takes 102 for its guest and records that guest |
Both get a confirmation. There is no mistake in any single line of code. The mistake is that the steps of the two requests interleave.
The fix: nobody should get in between the check and taking the room. Then "check and take" is atomic. In JavaScript our reservation already is. Node.js runs the code in one thread, and a synchronous function runs to the end before other code starts. There is no await in reserve, so no request can get in the middle. No lock is needed:
reserve(number, guest) { if (guest.trim() === "") { throw new HotelError("The guest's name is required."); } const room = this.#find(number); if (!room.isFree) { throw new RoomTakenError(`Room ${number} is already taken.`); } room.occupy(); this.#guests.set(number, guest);}await in the middle of the reservation. If the rooms were in a database, the check would wait for its answer with await. While it waits, another request checks the same room and sees it as free. So "check and take" has to be one synchronous step or one request to the database.reserve(number, guest) { if (guest.trim() === "") { throw new HotelError("The guest's name is required."); } const room = this.#find(number); if (!room.isFree) { throw new RoomTakenError(`Room ${number} is already taken.`); } room.occupy(); this.#guests.set(number, guest);}Step by step, in words
- Room 102 is free. Anna Petrova and Boris Iliev reserve it at the same moment.
- First with an await between the check and taking the room, as if the rooms were in a database. Anna's request checks, sees "free" and waits.
- While Anna waits, Boris's request checks: it sees "free" too.
- Anna takes the room and records her name.
- Boris takes the room too and records his name. Both have a confirmation for the same room.
- From the start, without await: reserve is synchronous and runs to the end before other code starts.
- Say Anna is first: her request has the turn. The check and the taking are one step, and nobody gets in between them.
- Boris's request has arrived and waits: there is one thread, and it does one thing at a time.
- Now it is Boris's turn. Now the check sees "taken".
- RoomTakenError. The reception sends it back as an error line, and Boris can ask for another room.
- The program from the lesson counts the ones that succeed: 1 for each room. Who was first is different on every run.
The proof: two clients reserve 102 at once, then twenty agencies reserve 201 at once. The replies are collected, and the program prints how many succeeded:
async function reserve(port, room, guest) { try { const client = new HotelClient("localhost", port); const reply = await client.send(Protocol.RESERVE, `${room};${guest}`); await client.close(); return reply; } catch (e) { return `${Protocol.ERROR}:${e.message}`; }}
async function race(port, room, guests) { const replies = await Promise.all(guests.map((guest) => reserve(port, room, guest))); return replies.filter((reply) => reply.startsWith(Protocol.OK)).length;}const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));const server = new HotelServer(5332, hotel);await server.start();
const two = ["Anna Petrova", "Boris Iliev"];console.log(`Two for room 102, succeeded: ${await race(5332, 102, two)}`);
const agencies = [];for (let i = 1; i <= 20; i++) { agencies.push(`Agency ${i}`);}console.log(`Twenty for room 201, succeeded: ${await race(5332, 201, agencies)}`);
const desk = new HotelClient("localhost", 5332);console.log(await desk.send(Protocol.FREE));await desk.close();await server.stop();Two for room 102, succeeded: 1Twenty for room 201, succeeded: 1OK:101, single, €60 per night, free; 202, suite, €140 per night, freePromise.all starts all the requests at once and waits for the replies, and race counts the ones that succeeded. There is one thread, and yet the requests still interleave: each one waits for the network while the others run.Who won the room is different on every run, so the program does not print it. How many succeeded is always the same: one for each room.
More precisely, if you are curious
-
With an
awaitbetween the check and taking the room, the bug is not rare: two people get the room on every run (see the next card). So everyawaitis a place to stop and think: what can another request change while this one waits. -
If you really need an
awaitin the middle of the reservation, the requests for one room line up one after another, for example with a queue of promises (Promise) or with a locking library. This project does not need it. - In a real system the rooms are in a database, and the guarding happens there: a transaction or a condition in the query itself (
UPDATE rooms SET free = false WHERE number = 102 AND free = true). Then it holds even with several servers, not only inside one program.
Try it
Locked, but not quite
async methods. The reception waits for the check first, then for the taking. Two clients at once get the same room on every run. Which line is the bug on?Pick the line with the bug.
await this.#hotel.isFree(number) the request stops and waits. Right then another request checks the same room and sees it as free. The check and the taking have to be one step with no await between them, as in our reserve.See it
The whole hotel
The test scenarios from the brief, this time over the network, with two clients connected at once. At the end two people reserve 201 at the same moment:
async function say(who, client, command, text) { const reply = await client.send(command, text); console.log(`${who} > ${command}:${text}`); console.log(`${who} < ${reply}`);}
const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));const server = new HotelServer(5333, hotel);await server.start();
const anna = new HotelClient("localhost", 5333);const boris = new HotelClient("localhost", 5333);await say("Anna", anna, Protocol.RESERVE, "102;Anna Petrova");await say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev");await say("Boris", boris, Protocol.CHECK, "102");await say("Boris", boris, Protocol.CANCEL, "102;Boris Iliev");await say("Anna", anna, Protocol.CANCEL, "102;Anna Petrova");await say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev");await say("Anna", anna, Protocol.CHECK, "305");await anna.close();await boris.close();
const two = ["Anna Petrova", "Vera Koleva"];console.log(`Two for room 201 at once, succeeded: ${await race(5333, 201, two)}`);const desk = new HotelClient("localhost", 5333);console.log(await desk.send(Protocol.FREE));await desk.close();await server.stop();Anna > RESERVE:102;Anna PetrovaAnna < OK:Room 102 is yours, Anna Petrova.Boris > RESERVE:102;Boris IlievBoris < ERROR:Room 102 is already taken.Boris > CHECK:102Boris < OK:102, double, €85 per night, takenBoris > CANCEL:102;Boris IlievBoris < ERROR:Room 102 is reserved by another guest.Anna > CANCEL:102;Anna PetrovaAnna < OK:The reservation for room 102 is canceled.Boris > RESERVE:102;Boris IlievBoris < OK:Room 102 is yours, Boris Iliev.Anna > CHECK:305Anna < ERROR:There is no room 305.Two for room 201 at once, succeeded: 1OK:101, single, €60 per night, free; 202, suite, €140 per night, freeMore precisely, if you are curious
The whole program, from start to finish:
import { createConnection, createServer } from "node:net";import { createInterface } from "node:readline";
class Room { #number; #type; #price; #free = true;
constructor(number, type, price) { this.#number = number; this.#type = type; this.#price = price; }
get number() { return this.#number; }
get type() { return this.#type; }
get price() { return this.#price; }
get isFree() { return this.#free; }
occupy() { this.#free = false; }
release() { this.#free = true; }
toString() { const state = this.#free ? "free" : "taken"; return `${this.#number}, ${this.#type}, €${this.#price} per night, ${state}`; }}
class Hotel { #rooms = []; #guests = new Map();
add(room) { this.#rooms.push(room); }
freeRooms() { return this.#rooms.filter((room) => room.isFree); }
check(number) { return this.#find(number); }
reserve(number, guest) { if (guest.trim() === "") { throw new HotelError("The guest's name is required."); } const room = this.#find(number); if (!room.isFree) { throw new RoomTakenError(`Room ${number} is already taken.`); } room.occupy(); this.#guests.set(number, guest); }
cancel(number, guest) { const room = this.#find(number); if (room.isFree) { throw new HotelError(`Room ${number} is not reserved.`); } if (this.#guests.get(number) !== guest) { throw new HotelError(`Room ${number} is reserved by another guest.`); } room.release(); this.#guests.delete(number); }
#find(number) { const room = this.#rooms.find((r) => r.number === number); if (room === undefined) { throw new RoomNotFoundError(`There is no room ${number}.`); } return room; }}
class HotelError extends Error { constructor(message) { super(message); this.name = "HotelError"; }}
class RoomNotFoundError extends HotelError { constructor(message) { super(message); this.name = "RoomNotFoundError"; }}
class RoomTakenError extends HotelError { constructor(message) { super(message); this.name = "RoomTakenError"; }}
const Protocol = Object.freeze({ FREE: "FREE", CHECK: "CHECK", RESERVE: "RESERVE", CANCEL: "CANCEL", EXIT: "EXIT",
OK: "OK", ERROR: "ERROR", BYE: "BYE",});
class Reception { #hotel;
constructor(hotel) { this.#hotel = hotel; }
handle(line) { const colon = line.indexOf(":"); const command = colon < 0 ? line : line.slice(0, colon); const text = colon < 0 ? "" : line.slice(colon + 1); try { switch (command) { case Protocol.FREE: return `${Protocol.OK}:${this.#join(this.#hotel.freeRooms())}`; case Protocol.CHECK: return `${Protocol.OK}:${this.#hotel.check(this.#number(text))}`; case Protocol.RESERVE: return this.#reserve(text); case Protocol.CANCEL: return this.#cancel(text); default: throw new HotelError(`Unknown command ${command}.`); } } catch (e) { if (!(e instanceof HotelError)) throw e; return `${Protocol.ERROR}:${e.message}`; } }
#reserve(text) { const [numberText, guest] = this.#numberAndGuest(text); const number = this.#number(numberText); this.#hotel.reserve(number, guest); return `${Protocol.OK}:Room ${number} is yours, ${guest}.`; }
#cancel(text) { const [numberText, guest] = this.#numberAndGuest(text); const number = this.#number(numberText); this.#hotel.cancel(number, guest); return `${Protocol.OK}:The reservation for room ${number} is canceled.`; }
#numberAndGuest(text) { const semicolon = text.indexOf(";"); if (semicolon < 0) { throw new HotelError("Write a number and a name, for example '102;Anna Petrova'."); } return [text.slice(0, semicolon), text.slice(semicolon + 1)]; }
#number(text) { const number = Number(text); if (text.trim() === "" || !Number.isInteger(number)) { throw new HotelError("The room number must be a whole number."); } return number; }
#join(rooms) { if (rooms.length === 0) { return "There are no free rooms."; } return rooms.join("; "); }}
class ClientHandler { #socket; #reception;
constructor(socket, reception) { this.#socket = socket; this.#reception = reception; }
async run() { try { for await (const line of createInterface({ input: this.#socket })) { if (line === Protocol.EXIT) { break; } this.#socket.write(`${this.#reception.handle(line)}\n`); } this.#socket.end(`${Protocol.BYE}\n`); } catch (e) { console.log(`The connection to the client was lost: ${e.message}`); } }}
class HotelServer { #server; #port;
constructor(port, hotel) { const reception = new Reception(hotel); this.#port = port; this.#server = createServer((socket) => new ClientHandler(socket, reception).run()); }
start() { return new Promise((resolve) => this.#server.listen(this.#port, "localhost", resolve)); }
stop() { return new Promise((resolve) => this.#server.close(resolve)); }}
class HotelClient { #socket; #lines;
constructor(host, port) { this.#socket = createConnection({ host, port }); this.#lines = createInterface({ input: this.#socket })[Symbol.asyncIterator](); }
async send(command, text) { const line = text === undefined ? command : `${command}:${text}`; this.#socket.write(`${line}\n`); return (await this.#lines.next()).value; }
async close() { await this.send(Protocol.EXIT); this.#socket.end(); }}
async function say(who, client, command, text) { const reply = await client.send(command, text); console.log(`${who} > ${command}:${text}`); console.log(`${who} < ${reply}`);}
async function reserve(port, room, guest) { try { const client = new HotelClient("localhost", port); const reply = await client.send(Protocol.RESERVE, `${room};${guest}`); await client.close(); return reply; } catch (e) { return `${Protocol.ERROR}:${e.message}`; }}
async function race(port, room, guests) { const replies = await Promise.all(guests.map((guest) => reserve(port, room, guest))); return replies.filter((reply) => reply.startsWith(Protocol.OK)).length;}
const hotel = new Hotel();hotel.add(new Room(101, "single", 60));hotel.add(new Room(102, "double", 85));hotel.add(new Room(201, "double", 95));hotel.add(new Room(202, "suite", 140));const server = new HotelServer(5333, hotel);await server.start();
const anna = new HotelClient("localhost", 5333);const boris = new HotelClient("localhost", 5333);await say("Anna", anna, Protocol.RESERVE, "102;Anna Petrova");await say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev");await say("Boris", boris, Protocol.CHECK, "102");await say("Boris", boris, Protocol.CANCEL, "102;Boris Iliev");await say("Anna", anna, Protocol.CANCEL, "102;Anna Petrova");await say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev");await say("Anna", anna, Protocol.CHECK, "305");await anna.close();await boris.close();
const two = ["Anna Petrova", "Vera Koleva"];console.log(`Two for room 201 at once, succeeded: ${await race(5333, 201, two)}`);const desk = new HotelClient("localhost", 5333);console.log(await desk.send(Protocol.FREE));await desk.close();await server.stop();Exercise
Keep going on your own
Work with the whole program from the previous card.
1. Changing rooms. The guest moves from one room to another with MOVE:102;202;Anna Petrova. The reply is OK:Anna Petrova is in room 202. If the new room is taken, the guest stays in the old one. The move is one method in Hotel, synchronous, with no await inside: nobody should take the new room between freeing the old one and taking the new one.
OK:Room 102 is yours, Anna Petrova.OK:Room 201 is yours, Boris Iliev.ERROR:Room 201 is already taken.OK:Anna Petrova is in room 202.OK:101, single, €60 per night, free; 102, double, €85 per night, freeMore ideas
- Nights and total. A reservation is for a number of nights, and the confirmation says the total: "3 nights, €255".
- Dates. A room is reserved for days, for example from December 14 to 16. Two reservations for the same room must not overlap. Whether the room is free depends on the dates.
- My reservations.
MINE:Anna Petrovasays which rooms belong to this guest. - Waiting list. If the room is taken, the guest signs up to wait. When it is canceled, it goes to the first one on the list.
-
A client from the keyboard. A second program reads commands from the keyboard with
ask()and sends them to the server, which runs in another terminal.
Test