Условие
Реализовать HTTP-сервис для управления бронированиями на PostgreSQL. Сервис должен поддерживать одновременные блокирующие запросы (тестовая система отправляет следующий запрос только после ответа на предыдущий).
Схема БД
CREATE TABLE bookings (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
place_id INTEGER NOT NULL,
time_from TIMESTAMP NOT NULL,
time_to TIMESTAMP NOT NULL
);API
GET /ping — health-check. Возвращает HTTP 200 с телом {"status":"ok"}.
POST /book — создать бронирование. Query-параметры: place_id, user_id, from, to (RFC 3339).
- HTTP 200 — успех;
- HTTP 409 — конфликт: интервал пересекается с существующим для этого места.
- Полуинтервалы
[from1, to1)и[from2, to2)пересекаются, еслиfrom1 < to2иfrom2 < to1.
GET /booklist — список бронирований. Один из параметров: user_id или place_id. Ответ HTTP 200 с JSON {"bookings": [...]}. Сортировка по (from, id) ASC.
Подключение к БД
Через переменные окружения: DB_HOST (default localhost), DB_PORT (5432), DB_USER (postgres), DB_PASSWORD (postgres), DB_NAME (contest).
Сервис принимает аргумент командной строки --port <port>.
Решение
Подход
Главная сложность — корректная обработка конкурентных запросов. Если два POST /book придут одновременно для одного места, нужно гарантировать, что только один пройдёт. Хотя тестовая система обещает блокирующие запросы, корректнее заложить транзакционную защиту.
Способы избежать гонок:
SERIALIZABLE-транзакции. Простое, но дорогое решение.- Advisory locks. Берём
pg_advisory_xact_lock(place_id)— блокировка поplace_idна время транзакции. - Exclusion constraint — самое идиоматичное:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
place_id WITH =,
tsrange(time_from, time_to, '[)') WITH &&
);При попытке вставить пересекающийся интервал PostgreSQL вернёт ошибку — её ловим и отдаём 409.
Реализация (Python + FastAPI + asyncpg)
import os
import argparse
from datetime import datetime
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import JSONResponse
import asyncpg
import uvicorn
app = FastAPI()
pool: asyncpg.Pool | None = None
@app.on_event("startup")
async def startup():
global pool
pool = await asyncpg.create_pool(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", "5432")),
user=os.getenv("DB_USER", "postgres"),
password=os.getenv("DB_PASSWORD", "postgres"),
database=os.getenv("DB_NAME", "contest"),
min_size=1, max_size=10,
)
async with pool.acquire() as conn:
await conn.execute("CREATE EXTENSION IF NOT EXISTS btree_gist")
await conn.execute("""
CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
place_id INTEGER NOT NULL,
time_from TIMESTAMP NOT NULL,
time_to TIMESTAMP NOT NULL
)
""")
await conn.execute("""
ALTER TABLE bookings
DROP CONSTRAINT IF EXISTS no_overlap,
ADD CONSTRAINT no_overlap EXCLUDE USING gist (
place_id WITH =,
tsrange(time_from, time_to, '[)') WITH &&
)
""")
@app.get("/ping")
async def ping():
return {"status": "ok"}
@app.post("/book")
async def book(place_id: int, user_id: int,
from_: str = Query(..., alias="from"),
to: str = Query(...)):
f = datetime.fromisoformat(from_.replace("Z", "+00:00"))
t = datetime.fromisoformat(to.replace("Z", "+00:00"))
if f >= t:
raise HTTPException(400, "Invalid interval")
try:
async with pool.acquire() as conn:
await conn.execute(
"INSERT INTO bookings (user_id, place_id, time_from, time_to) "
"VALUES ($1, $2, $3, $4)",
user_id, place_id, f, t
)
return JSONResponse({"status": "ok"}, status_code=200)
except asyncpg.exceptions.ExclusionViolationError:
raise HTTPException(409, "Conflict")
@app.get("/booklist")
async def booklist(user_id: int | None = None, place_id: int | None = None):
if (user_id is None) == (place_id is None):
raise HTTPException(400, "exactly one of user_id, place_id required")
if user_id is not None:
rows = await pool.fetch(
"SELECT id, user_id, place_id, time_from, time_to "
"FROM bookings WHERE user_id = $1 ORDER BY time_from, id", user_id
)
else:
rows = await pool.fetch(
"SELECT id, user_id, place_id, time_from, time_to "
"FROM bookings WHERE place_id = $1 ORDER BY time_from, id", place_id
)
return {"bookings": [
{
"id": r["id"], "user_id": r["user_id"], "place_id": r["place_id"],
"from": r["time_from"].isoformat() + "Z",
"to": r["time_to"].isoformat() + "Z",
} for r in rows
]}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, required=True)
args = parser.parse_args()
uvicorn.run(app, host="0.0.0.0", port=args.port)Подводные камни
- Полуинтервал, не интервал. Бронирование
[10:00, 12:00)и[12:00, 14:00)НЕ конфликтуют. Используемtsrange(..., '[)'), не'[]'. from— зарезервированное слово. В Python нельзя завести аргументfrom. Используемalias="from"в FastAPI.- Таймзона.
RFC 3339поддерживаетZи+00:00. В Python 3.11+fromisoformatпонимает+00:00, дляZ— заменяем. - Сортировка
(from, id). Если у двух бронирований одинаковыйfrom, нужен детерминированный порядок — поid. - Конкуренция.
EXCLUDEconstraint — идиоматичный способ. Без него альтернативы:SERIALIZABLEилиpg_advisory_lock(place_id)в транзакции, иначе два одновременныхPOSTмогут оба пройти. - Health check. Тестировщик отправляет
/ping— он должен работать без БД (или хотя бы быстро). Не делайте в нём тяжёлой проверки. SERIAL PRIMARY KEYиIDENTITY. В PG 10+ предпочтительнееGENERATED ALWAYS AS IDENTITY, ноSERIALостаётся валидным.
Эталонный ответ
HTTP-сервис на FastAPI/asyncpg + PostgreSQL с EXCLUDE USING gist для атомарной защиты от пересечений по (place_id, tsrange). Конфликты ловим как ExclusionViolationError → 409. Сортировка списков — по (from, id).