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 без фиксированного зерна, — везде, где значение переживает процесс, который его породил.

Отсюда правило для тестов: свойство, которое ломается между запусками, и проверять надо между запусками. Тест в одном интерпретаторе на такое свойство всегда зелёный и создаёт ложное чувство покрытия. Если инвариант звучит как «одинаково при перезапуске / на другой машине / в другом процессе» — тест обязан породить второй процесс.