Собесов

Backend Яндекса — HTTP-сервис бронирований на PostgreSQL

SQLHTTP-сервис и транзакцииСложнаяSenior

Условие

Реализовать 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 придут одновременно для одного места, нужно гарантировать, что только один пройдёт. Хотя тестовая система обещает блокирующие запросы, корректнее заложить транзакционную защиту.

Способы избежать гонок:

  1. SERIALIZABLE-транзакции. Простое, но дорогое решение.
  2. Advisory locks. Берём pg_advisory_xact_lock(place_id) — блокировка по place_id на время транзакции.
  3. 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)

Подводные камни

  1. Полуинтервал, не интервал. Бронирование [10:00, 12:00) и [12:00, 14:00) НЕ конфликтуют. Используем tsrange(..., '[)'), не '[]'.
  2. from — зарезервированное слово. В Python нельзя завести аргумент from. Используем alias="from" в FastAPI.
  3. Таймзона. RFC 3339 поддерживает Z и +00:00. В Python 3.11+ fromisoformat понимает +00:00, для Z — заменяем.
  4. Сортировка (from, id). Если у двух бронирований одинаковый from, нужен детерминированный порядок — по id.
  5. Конкуренция. EXCLUDE constraint — идиоматичный способ. Без него альтернативы: SERIALIZABLE или pg_advisory_lock(place_id) в транзакции, иначе два одновременных POST могут оба пройти.
  6. Health check. Тестировщик отправляет /ping — он должен работать без БД (или хотя бы быстро). Не делайте в нём тяжёлой проверки.
  7. 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).

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти