Dubbelbokning är ett databasfel
Exclusion constraints i Postgres, inkopplade i Ash som ett vanligt fältfel. Två klick i samma millisekund kan inte vinna båda.
En camping har en plats B4. Två gäster öppnar bokningen samtidigt, väljer samma helg och trycker på knappen i samma ögonblick. Hur många bokningar finns det efteråt?
Det vanliga svaret är en kontroll i koden: läs om platsen är ledig, och skriv om den är det. Det fungerar tills två processer läser samtidigt, båda ser en ledig plats och båda skriver. På BEAM, där varje förfrågan är sin egen process och tusentals körs samtidigt, är det inte ett teoretiskt problem.
Låt databasen säga nej
Postgres kan uttrycka regeln direkt: inga två rader där platsen är samma och tiderna överlappar.
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bokningar ADD CONSTRAINT bokningar_plats_ingen_overlapp
EXCLUDE USING gist (plats_id WITH =, tstzrange(starts_at, ends_at) WITH &&)
WHERE (status IN ('hold', 'bekraftad') AND plats_id IS NOT NULL);
btree_gist behövs för att ett GiST-index ska kunna jämföra en uuid med = i samma index som intervallet jämförs med &&. Villkoret gör att bara aktiva bokningar deltar. En avbokad bokning släpper tiden direkt, utan att någon behöver städa.
En detalj som är lätt att missa: tstzrange är halvöppen som standard, [). En avresa klockan 11 och en ankomst klockan 11 överlappar alltså inte. Det är precis vad en camping vill.
Felet blir ett fältfel
Utan mer arbete skulle den förlorande transaktionen få ett 23P01 från Postgres och gästen en krasch. I AshPostgres kopplar man namnet på constrainten till ett fält och ett meddelande:
postgres do
table "bokningar"
repo Revelius.Repo
exclusion_constraint_names [
{:starts_at, "bokningar_plats_ingen_overlapp", "platsen är redan bokad då"},
{:starts_at, "bokningar_sak_ingen_overlapp", "den är redan bokad då"}
]
end
Nu kommer konflikten tillbaka som ett vanligt valideringsfel på starts_at. LiveView-formuläret visar det under fältet, som vilket annat fel som helst. Ingen specialkod i vyn, ingen retry-loop, inget lås i applikationen.
En sak eller en plats, aldrig båda
En bokning gäller antingen en plats på kartan eller en sak, som en solariebädd. Det är också en regel i databasen:
check_constraints do
check_constraint :plats_id, "bokningar_en_sak",
check: "(plats_id IS NULL) <> (sak_id IS NULL)",
message: "en bokning gäller en plats eller en sak"
end
Det finns en exclusion constraint per sort, en för platser och en för saker, och samma mönster skyddar mötena i kalendern.
Med en kund per schema
Varje kund har ett eget schema i Postgres. Constraints som ska finnas i varje schema skriver vi för hand i tenant-migreringen, så att prefixet blir rätt. Ash känner till dem genom namnen ovan, och migreringen körs i alla scheman vid deploy.
Vad testet behöver bevisa
Eftersom regeln ligger i databasen behöver testet inte iscensätta ett race. Det räcker att spara två överlappande bokningar efter varandra och kräva att den andra får fältfelet "platsen är redan bokad då". Constrainten gäller lika för två transaktioner i samma millisekund som för två med en timme emellan.
Där vi faktiskt måste prova samtidighet, i kod som skyddar sig själv i applikationen, gör vi det på riktiga anslutningar. Ecto-sandboxen delar en anslutning per test och serialiserar allt, så ett race i sandboxen bevisar ingenting. Det är ytterligare ett skäl att lägga regler som den här i databasen: de är billiga att testa ärligt.
Regler som bara finns i en knapp gäller bara för den som trycker på just den knappen.
Skrivet av Tavus Sweden AB, som bygger Revelius i Elixir. Fler texter.