Постепенный rollout в Python: как закрепить клиента за группой

В кабинете компании появился новый экспорт. Команда хочет открыть его примерно пяти процентам компаний, посмотреть на нагрузку и расширить доступ. Участники первой группы должны видеть один и тот же интерфейс на всех серверах. Если каждый запрос выбирает группу случайно, кнопка будет то появляться, то исчезать у одного и того же человека.

Группу можно вычислять из стабильного идентификатора компании и имени функции. В Python для этого легко взять hash(tenant_id) % 100. Такая реализация может выглядеть исправной при локальной проверке. Но хеши строк в CPython по умолчанию зависят от случайной настройки процесса. После перезапуска или при попадании запроса на другой экземпляр тот же идентификатор может дать другую группу.

Один идентификатор даёт одну координату

Закодируйте пару «имя переключателя и ID компании» как байты и вычислите SHA-256 через hashlib. Для распределения возьмите первые восемь байтов и преобразуйте в целое число в порядке от старшего байта к младшему. Затем отобразите этот диапазон на целые позиции от 0 до 9999. Порог 500 открывает около 5% пространства, порог 2000 около 20%. Координата клиента остаётся прежней, поэтому при повышении порога каждый прежний участник сохраняет доступ.

Имя переключателя входит в хеш. С одним именем и одной компанией все экземпляры вычисляют одинаковую позицию. Для другой функции позиция вычисляется заново. Выбирайте ID единицы выпуска по смыслу задачи. Если интерфейс общий для всех сотрудников компании, используйте её ID. При выборе ID пользователя сотрудники одной компании могут увидеть разные версии одного экрана.

import json
import os
import subprocess
import sys
from hashlib import sha256
from pathlib import Path

BUCKETS = 10_000


def bucket(flag: str, tenant_id: str) -> int:
    payload = json.dumps([flag, tenant_id], ensure_ascii=False, separators=(",", ":"))
    digest = sha256(payload.encode("utf-8")).digest()
    value = int.from_bytes(digest[:8], byteorder="big")
    return value * BUCKETS // (1 << 64)


def enabled(flag: str, tenant_id: str, threshold: int) -> bool:
    if not 0 <= threshold <= BUCKETS:
        raise ValueError("threshold must be between 0 and 10000")
    return bucket(flag, tenant_id) < threshold


def verify() -> None:
    flag = "new-export-v1"
    tenants = [f"company-{i}" for i in range(1000)]
    initial = {tenant for tenant in tenants if enabled(flag, tenant, 500)}
    expanded = {tenant for tenant in tenants if enabled(flag, tenant, 2000)}
    assert initial < expanded
    assert all(not enabled(flag, tenant, 0) for tenant in tenants)
    assert all(enabled(flag, tenant, 10_000) for tenant in tenants)

    sample = "компания-42"
    expected = str(bucket(flag, sample))
    for seed in ("17", "91"):
        process = subprocess.run(
            [sys.executable, str(Path(__file__).resolve()), flag, sample],
            env={**os.environ, "PYTHONHASHSEED": seed},
            check=True, capture_output=True, text=True,
        )
        assert process.stdout.strip() == expected
    print(f"first stage: {len(initial)}, expanded: {len(expanded)}, sample: {expected}")


if __name__ == "__main__":
    if len(sys.argv) == 3:
        print(bucket(sys.argv[1], sys.argv[2]))
    else:
        verify()

Сохраните код как rollout.py и запустите python rollout.py. Проверка строит две группы на одних и тех же 1000 компаниях и убеждается, что вторая содержит первую. Два дочерних процесса с разными PYTHONHASHSEED должны вывести ту же позицию для идентификатора с кириллицей. Значение вычисляется заново в каждом процессе. Доля попавших компаний зависит от набора идентификаторов, поэтому в проверке группы не требуется ровно 50 и 200 компаний.

Храните имя, алгоритм и порог как части настройки

При расширении меняйте только порог. Если изменить имя переключателя или способ кодирования, изменятся позиции и состав первой группы. При переносе вычисления в другой язык сохраните тестовые пары с ожидаемыми позициями. Особое внимание уделите порядку полей, кодировке и записи не-ASCII символов. Идентификатор берите из общей учётной записи, где его представление уже закреплено, чтобы 0042 и 42 не стали двумя вариантами одного клиента.

До выпуска запишите вместе с решением имя переключателя, порог и единицу распределения. Когда поддержка получает вопрос о пропавшей функции, по этим данным можно повторить вычисление и найти экземпляр со старой конфигурацией. Доступ к платному действию проверяйте отдельным правилом по данным аккаунта. Порог нужен для постепенного включения интерфейса и применяется после того, как сервис определил, кому функция в принципе доступна.

На платном курсе Latorn «Python для профессионалов» есть модули по возможностям стандартной библиотеки и внутреннему устройству CPython. Разберите, какие свойства встроенных операций сохраняются между запусками, и научитесь проверять предположения о работе кода. Посмотреть программу курса «Python для профессионалов».

Продолжить на практике

  • Python для профессионалов ↗

    Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.