В прошлых статьях я рассказывал, как устроен мой тренажёр инцидентов, и разбирал кейс с 502 Bad Gateway. За это время я релизнул два новых режима: «дежурство недели» и публичный профиль игрока. Покажу, как они устроены: там нет ни крона, ни новых таблиц, и всё равно есть пара мест, где легко ошибиться.
Один кейс на неделю
Правило простое: каждую неделю один кейс и отдельный рейтинг по нему. Неделя начинается в понедельник в 00:00 по Москве.
Первое желание — повесить на понедельник задачу по крону, которая выберет кейс. Но крон — это ещё одна движущаяся часть: сервер был выключен в полночь, задача упала, часовой пояс в контейнере не тот. Поэтому кейс выбирается лениво, при первом запросе на неделе, и запоминается в таблице настроек:
def case_for_week(start): key = f"weekly:{start:%G-W%V}" stored = database.get_setting(key) if stored in SCENARIOS: return stored recent = {database.get_setting(week_key(start - timedelta(weeks=n))) for n in range(1, 5)} fresh = [sid for sid in eligible_cases() if sid not in recent] or eligible_cases() case_id = random.Random(key).choice(fresh) database.set_setting(key, case_id) return case_id
Здесь три детали:
Генератор случайных чисел инициализируется ключом недели. Если два запроса придут одновременно, оба выберут один и тот же кейс, и блокировки не нужны.
Выбор сохраняется. Если посреди недели я добавлю новый кейс, список кандидатов изменится, но кейс недели останется прежним.
Последние четыре недели исключаются, чтобы кейс не выпадал два раза подряд. Обучающий кейс и кейсы, которые открываются только после прохождения других, в розыгрыш не попадают.
Рейтинг — просто запрос
Отдельной таблицы для рейтинга нет: все попытки и так лежат в runs. В зачёт идёт попытка, взятая в работу и закрытая в пределах недели:
SELECT runs.owner, runs.score, runs.solved_at - runs.started_at AS seconds, CASE WHEN users.leaderboard_opt_in = 1 AND users.is_admin = 0 THEN users.username END AS username FROM runs LEFT JOIN users ON runs.owner = 'u' || users.id WHERE runs.scenario_id = ? AND runs.started_at >= ? AND runs.solved_at < ? AND runs.staff = 0
Лучшая попытка игрока выбирается уже в Python: сначала по очкам, потом по времени. Очки идут первыми: подсказки их снижают, и тот, кто разобрался сам, должен стоять выше того, кто открыл все три подсказки и успел на минуту раньше.
Имя в ответе есть только у тех, кто сам согласился участвовать в таблице лидеров. Остальные попадают в рейтинг недели одной цифрой «закрыли N человек». Мои собственные проверки (staff = 0) не попадают никуда, иначе я бы каждую неделю занимал первое место, было бы не честно:)
Профиль: 404 вместо «доступ запрещён»
Профиль /u/<имя> показывает решённые кейсы, достижения и лучшие времена. Это публикация персональных данных, поэтому он открывается по тому же согласию, что и таблица лидеров:
user = database.get_user_by_username(username) if user is None or not user["leaderboard_opt_in"] or user["is_admin"]: abort(404)
Скрытый профиль отдаёт тот же 404, что и несуществующий. С ответом 403 по разнице кодов можно было бы перебором проверить, какие имена зарегистрированы. Страница профиля помечена noindex: делиться ссылкой можно, но в поисковой выдаче имени игрока не будет.
Что в итоге
Без крона, новых таблиц и фоновых задач вышло около 300 строк вместе с шаблонами и 130 строк тестов. Кейс этой недели — тот самый 502 из прошлой статьи.
Как вы считаете, стоящая тема?
Если есть предложения или пожелания чтобы вы хотели видеть — пишите в комментариях. С удовольствием прочитаю, а лучшие добавлю в кейсы.