hash() стабилен только внутри одного процесса
Симптом
Прод, сервис автопубликации новостного контента. Один из веб-источников занимает
42% таблицы parse — 3.47 млн строк из 8.28 млн, при том что уникальных URL у
него всего 1575. Таблица весит 22 GB, из них 8.8 GB — один только текст постов
этого источника.
Замер за сутки:
источник | строк | уникальных id | уникальных url | копий на url
source-x | 16808 | 16808 | 80 | 210.1
source-a | 791 | 791 | 791 | 1.0
source-b | 552 | 552 | 552 | 1.0
source-c | 513 | 513 | 513 | 1.0
source-d | 210 | 210 | 210 | 1.0
Каждая из 80 статей заезжает в базу 210 раз в сутки, и каждый раз с новым
post_id. Остальные веб-источники такой херней не занимаются.
Копилось одиннадцать месяцев и не мешало никому, пока была сломана маршрутизация. Как только маршрутизацию починили, поток пошёл в очередь: 601 строка за час, AI-конвейер успел сделать 16 постов.
Контекст
Проект большой, древний, из до ИИ эпохи, но сделан через жопу во многих местах.
Раннер парсеров не держит их в памяти. На каждый цикл он поднимает новый
subprocess scrapy crawl, дожидается завершения, и так по кругу.
Дедупликация постов идёт по (source_id, post_id) — уникальный индекс в каждой
месячной партиции.
Парсер проблемного источника выводил post_id из slug:
numeric_id_from_hash = abs(hash(slug)) % (10 ** 10)
В том же проекте двумя месяцами раньше уже появилась правильная функция в общем модуле утилит парсеров:
def stable_bigint_id(raw: str) -> int:
"""Stable positive id that fits PostgreSQL BIGINT (signed 64-bit)."""
return int(hashlib.sha256(raw.encode()).hexdigest()[:15], 16)
Корневая причина
hash() для строк в Python рандомизирован с версии 3.3
(PEP 456): интерпретатор
при старте берёт случайное зерно и подмешивает его в хеш строк и байтов — защита
от hash-flooding. Зерно живёт ровно столько, сколько живёт процесс.
PYTHONHASHSEED не задан ни в репозитории, ни в работающем контейнере — проверено
printenv внутри контейнера (переменная отсутствует) и поиском по образу,
compose-файлу и env-файлам.
Новый subprocess каждые ~7 минут = новое зерно = новые ID для всех статей, которые он видит.
Ни один защитный механизм это не ловил, потому что все они завязаны на
(source_id, post_id): для базы 210 копий с разными post_id — это 210 честно
разных постов. Дубликат виден только по URL, а по нему никто не сверялся.
Ошибочное предположение
Что hash() даёт одинаковый идентификатор для одной и той же строки.
Дает, пока сервис живёт одним долгим процессом. Как только он переходит на subprocess - hash перестает гарантировать детерминизм.
Тип и диапазон значения при этом остаются безупречными, поэтому ни схема, ни индексы, ни ревью на это не реагируют.
Обнаружение
Раньше бы это поймала метрика, отслеживающее отношение числа строк к числу
уникальных URL за окно. Для здорового источника она равна 1, здесь была 210.
Считается одним запросом, ловит любой источник нестабильности ID, не только
hash().
Второй сигнал, более грубый: доля одного источника в общем объёме таблицы. 42% от одного новостного сайта — аномалия, видимая без всякого понимания причины.
Исправление
Перевел все парсеры на sha хэш с гарантированным детерминизмом, затем аккуратно, но сильно вычистил из postgres 12гб мусорных строк, затем пылесосил VACUUM.
Проверка
Юнит-тесты спайдера. Ключевые два считают ID в двух подпроцессах с
PYTHONHASHSEED=0 и PYTHONHASHSEED=1 и требуют совпадения:
def test_slug_id_survives_a_different_hash_seed():
assert news_id_under_hash_seed(SLUG_URL, "0") == news_id_under_hash_seed(SLUG_URL, "1")
Изнутри одного интерпретатора это свойство проверить нельзя - рандомизация
действует на процесс целиком, и любой обычный ассерт на hash() проходит.
Ещё два теста на контракт: ID влезает в int8, числовой URL сохраняет свой
собственный ID.
Проверка на осмысленность: против кода до фикса падают оба seed-теста, контрактные проходят в обеих версиях.
Общий вывод
Идентификатор, выведенный из процесс-локальной функции, не является
идентификатором: он совпадает с собой только внутри одного запуска. Запрещать надо
весь класс источников — hash(), id(), uuid4(), время, PRNG без
фиксированного зерна, — везде, где значение переживает процесс, который его
породил.
Отсюда правило для тестов: свойство, которое ломается между запусками, и проверять надо между запусками. Тест в одном интерпретаторе на такое свойство всегда зелёный и создаёт ложное чувство покрытия. Если инвариант звучит как «одинаково при перезапуске / на другой машине / в другом процессе» — тест обязан породить второй процесс.