Deep Engineering

MEASUREMENT

bench/stretched-cache/cluster.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/system-design/caching/stretched-cache
Run on
Redis 7.0.15, PostgreSQL 16.13, Python 3.11.15, redis-py 8.1.0
How to run it
python3 bench/stretched-cache/replication.py   # блоки 1-3
python3 bench/stretched-cache/cluster.py       # блоки 4-6
python3 bench/stretched-cache/latency.py       # блок 7
python3 bench/stretched-cache/readsdown.py     # блок 8
python3 bench/stretched-cache/offsets.py       # блок 9
python3 bench/stretched-cache/waitcost.py      # блок 10
python3 bench/stretched-cache/relaycheck.py    # блоки 11-14
python3 bench/stretched-cache/versions.py      # блок 15

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для статьи «Растянутый кэш на два датацентра»

Скрипт Что делает
link.py канал между датацентрами: ретранслятор в пользовательском коде, который задерживает байты и умеет обрываться. Два варианта: конвейерный DelayedLink и сериализующий SerializingLink — разница между ними измерена в блоке 13. Общий модуль, сам ничего не печатает
replication.py мастер в одном ДЦ, реплика в другом: отставание, потеря подтверждённых записей при переключении, цена WAIT — блоки 1–3
cluster.py кластер из шести узлов, три раскладки по датацентрам, разрыв канала — блоки 4–6
latency.py кэш в чужом ДЦ против базы в своём — блок 7
readsdown.py что решает cluster-allow-reads-when-down — блок 8
offsets.py чем измеряется окно потерь: отставание против круга — блок 9
waitcost.py из чего складывается цена WAIT, на трёх расстояниях — блок 10
relaycheck.py проверка самого стенда: во что он обходится при нулевой задержке, сколько раз задержка применяется к операции, сериализующий канал против конвейерного, калибровка — блоки 11–14
versions.py те же утверждения на Redis 7.0.15, Redis 8.10.1 и Valkey 9.1.2 — блок 15
python3 bench/stretched-cache/replication.py   # блоки 1-3
python3 bench/stretched-cache/cluster.py       # блоки 4-6
python3 bench/stretched-cache/latency.py       # блок 7
python3 bench/stretched-cache/readsdown.py     # блок 8
python3 bench/stretched-cache/offsets.py       # блок 9
python3 bench/stretched-cache/waitcost.py      # блок 10
python3 bench/stretched-cache/relaycheck.py    # блоки 11-14
python3 bench/stretched-cache/versions.py      # блок 15

Нужен redis-server в PATH, для кластера ещё и redis-cli, для latency.py — работающий PostgreSQL (DSN в DE_TEST_DATABASE_URL). Другую сборку можно подсунуть через REDIS_SERVER_BIN и REDIS_CLI_BIN; versions.py берёт список из DE_REDIS_BUILDS вида «имя=каталог/bin» через запятую. Узлы поднимаются во временных каталогах на случайных портах и убиваются в finally; после себя скрипты не оставляют ни процессов, ни файлов.

Чем изображается разрыв, и почему двумя разными способами

В replication.py, latency.py, offsets.py, waitcost.py и relaycheck.py — ретранслятором link.py. Он слушает порт, соединяется с настоящим Redis и переносит байты, задерживая каждую порцию на заданное время. tc netem был бы честнее, но в ядре этой среды его попросту нет: CONFIG_NET_SCH_NETEM не собран, и tc qdisc add ... netem отвечает Error: Specified qdisc kind is unknown. Права тут ни при чём.

И у этого ретранслятора нашлось два дефекта — оба измерены и устранены. Первый: на его двух соединениях не был выставлен TCP_NODELAY, и пара «алгоритм Нейгла — отложенное подтверждение» добавляла сорок миллисекунд к каждой операции, где по каналу шло несколько мелких сообщений подряд. Ровно эти сорок миллисекунд первая версия статьи опубликовала как «слагаемое неизвестной природы» в цене WAIT. Второй: задержка ставилась между recv и sendall в одном потоке, поэтому порции шли по очереди, а не параллельно, и операция стоила на один лишний перелёт дороже. Пока первый дефект был на месте, второй был невидим — он вчетверо меньше. Подробности печатает relaycheck.py.

У ретранслятора есть cut(): он закрывает все соединения и перестаёт принимать новые. Это важнее задержки. Разрыв между датацентрами — это не «стало медленно», а «перестало ходить вовсе», и изображать его увеличенной задержкой нельзя: репликация с задержкой в десять секунд всё равно догонит, а оборванная — нет.

В cluster.py — сигналом STOP дальней половине. Здесь ретранслятор не годится: узлы кластера общаются друг с другом по шине напрямую, а не через клиентский порт, и ставить ретранслятор пришлось бы на каждую пару. Процесс, остановленный SIGSTOP, жив и держит порт открытым, но не отвечает и не шлёт heartbeat — для оставшейся половины он неотличим от узла за оборванным каналом. Убить узлы было нельзя: вторую половину надо было опросить тоже, и а каждая половина наблюдается на СВОЁМ свежем кластере с той же, заданной руками топологией. Это исправление: раньше обе половины проверялись по очереди на одном кластере, и повышение реплики в первой проверке могло изменить топологию для второй. Две последовательные проверки — не две стороны одного разрыва.

Что здесь важно прочитать правильно

Абсолютные миллисекунды не переносятся никуда — ни на другую машину, ни на другую версию Redis. Содержательны отношение внутри блока, знак разницы и то, какая половина кластера ответила, а какая нет. Двадцать миллисекунд в одну сторону выбраны не «чтобы было хуже», а как обычное расстояние между датацентрами в пределах одной страны.

Номера портов в блоках 4–6 случайны и в каждом прогоне свои. Читаются не они, а роли узлов и столбец состояния. Именно поэтому в блоке 5 стоит проследить пальцем, чья реплика где: в этом весь результат.

Блоки 5 и 6 различаются одной перестановкой реплики. Деление узлов по датацентрам в них одинаковое; меняется только то, чью реплику оставили дома. Разница в исходе — между «отказали обе половины» и «одна работает».

Замер задержки сравнивает самую дешёвую операцию базы. Пять тысяч строк, всё в памяти, чтение по первичному ключу. Из того, что первые две строки блока 7 почти совпали, не следует, что кэш не нужен, — следует только правило: поход в кэш обязан быть дешевле той операции источника, которую он заменяет, на этом расстоянии. Для дорогого агрегата ответ может быть и другим, и здесь он не мерился.

Опубликованная основа — Redis 7.0.15, но она не единственная проверка. Блок 15 (versions.py) прогоняет те же утверждения на Redis 8.10.1 и Valkey 9.1.2: ни одно не разошлось. Числа блоков 1–10 при этом на новые версии не переносятся и не заменяются ими — вопрос блока 15 другой: не разъехалось ли поведение.

Что получилось (Redis 7.0.15, PostgreSQL 16.13, Python 3.11.15, redis-py 8.1.0)

Мастер и реплика, канал 20 мс в одну сторону:

что значение
реплика в том же ДЦ: запись видна через 1,1 мс
реплика в другом ДЦ: запись видна через 20,1 мс

В этом опыте при низкой нагрузке дополнительная задержка видимости почти совпала с добавленным перелётом в одну сторону. Совпадение не есть закон: обработка, буферизация и нагрузка добавляют своё.

Переключение на реплику, 200 подтверждённых клиенту записей:

когда оборвали оказалось на реплике потеряно
сразу после записи 107 93 (46,5 %)
через секунду после записи 200 0

Обе строки — один и тот же опыт с единственной разницей: успела ли репликация догнать до обрыва. Проценты — не свойство Redis; переносится граница: под угрозой то, что не доехало до реплики. Чем эта граница измеряется, см. ниже — здесь первая версия ошибалась.

Цена WAIT 1:

что значение
обычная запись 0,24 мс
запись с WAIT 1 41,73 мс
во сколько раз дороже ×171,6

Это ровно один круг до реплики — разбор на трёх расстояниях ниже. Купленное свойство в любом случае слабее ожидаемого: WAIT сообщает число подтвердивших реплик, но не отменяет запись, если их меньше.

Кластер из шести узлов, разрыв канала между датацентрами:

раскладка ДЦ-1 ДЦ-2
A: все мастера в ДЦ-1, все реплики в ДЦ-2 работает CLUSTERDOWN
B: узлы поровну, мастера 2 и 1 CLUSTERDOWN CLUSTERDOWN
C: то же деление, реплика чужого мастера дома работает CLUSTERDOWN

Главный результат — строка B. У ДЦ-1 большинство мастеров, и голосов на повышение реплики ему хватает, а кэша всё равно нет: реплика мастера, оставшегося в ДЦ-2, тоже осталась в ДЦ-2, и слоты этого мастера не покрыты никем.

Задержка, медиана из 200 замеров:

откуда читаем медиана p95
кэш в своём ДЦ 0,10 мс 0,14 мс
база в своём ДЦ 0,09 мс 0,13 мс
кэш в чужом ДЦ 41,09 мс 41,36 мс

Выводы, которые пришлось исправить

Разборов снаружи было два. Первый указал на три вывода, сделанных из одной точки каждый; второй — на сам измерительный стенд. Все указанные места пришлось не переписать словами, а перемерить, и четыре вывода из пяти оказались неверны.

«Наблюдаемое поведение шире документации» — неверно. Было сказано: раз cluster-require-full-coverage описан через записи, а GET тоже получил CLUSTERDOWN, значит поведение шире написанного. На самом деле за чтения отвечает отдельная описанная настройка cluster-allow-reads-when-down, по умолчанию no. readsdown.py меряет обе: с no двадцать ключей из двадцати получают CLUSTERDOWN; с yes узел отдаёт только ключи своих слотов — четырнадцать из двадцати, — остальные перенаправляет за оборванный канал, а записи отказаны при обоих значениях.

«Под угрозой всё, что записано за последний круг» — неверно. Круг совпал с потерей случайно. offsets.py меняет один только темп записи на том же канале и получает 104, 3 и 0 потерянных записей при отставании 3548, 99 и 0 байт. Мерой служит разница смещений потока репликации, а не круг.

«82 мс — это два круга» — неверно, и заменившее его объяснение тоже. waitcost.py показал, что фиксированным числом кругов цена не описывается, и статья честно написала, что сверх круга остаётся слагаемое в 42–44 мс неизвестной природы. Природа оказалась в приборе: relaycheck.py включает и выключает TCP_NODELAY на ретрансляторе при НУЛЕВОЙ задержке и получает 44,00 и 0,70 мс. После исправления цена WAIT — ровно один круг: 10,90, 41,52 и 81,40 мс при отношении 1,09, 1,04 и 1,02.

«Две половины одного разрыва» — так называть две последовательные проверки было нельзя. После первой проверки топология не обязана вернуться к исходной: реплика могла повыситься, слоты переехать. cluster.py теперь поднимает свежий кластер на каждую сторону и печатает топологию до и после, а также смену ролей.

«Большинство плюс реплика каждого потерянного мастера» — условие неполное. Третья часть — свежесть реплики: отставшая сверх cluster-replica-validity-factor выборы не начнёт. В этих опытах она не мешала, и это теперь напечатано, а не предполагается.

Источники

Script

426 lines
"""Один кластер на два датацентра: что делает каждая половина при разрыве.

ПОЧЕМУ ЭТО ГЛАВНЫЙ ЗАМЕР СТАТЬИ. Просьба звучит как «разложите узлы поровну
между ДЦ, чтобы пережить потерю любого из них». Звучит симметрично и потому
убедительно. Замер показывает, что именно симметрия и убивает: решение о
том, кто продолжает работать, кластер принимает большинством голосов
мастеров, а у ровно поделённого пополам кластера большинства нет НИ У КОГО.

ЧТО ИМЕННО ПРОВЕРЯЕТСЯ. Три расклада на шести узлах:
  A. 3 мастера в ДЦ-1, 3 реплики в ДЦ-2 — «реплики про запас»;
  B. по 3 узла в каждом ДЦ так, что мастера поделены 2:1, и реплика третьего
     мастера осталась при нём;
  C. то же деление узлов, но реплика третьего мастера перенесена в ДЦ-1.

КАЖДАЯ СТОРОНА МЕРЯЕТСЯ НА СВОЁМ КЛАСТЕРЕ, И ЭТО ИСПРАВЛЕНИЕ. В первой версии
замера обе половины опрашивались по очереди на одном кластере: остановили
дальнюю, спросили ближнюю, отпустили, остановили ближнюю, спросили дальнюю.
Разбор снаружи указал на дыру, и указал верно. После первой проверки топология
не обязана вернуться к исходной: в раскладе C первая половина успевает
ПОВЫСИТЬ реплику, сменить configEpoch и переназначить слоты, — и вторая
половина меряется уже на другом кластере, чем задумано. Две последовательные
проверки нельзя называть двумя сторонами одного разрыва.

Поэтому здесь на каждую сторону поднимается свой кластер с той же топологией,
и топология печатается ДО разрыва и ПОСЛЕ него — чтобы смену ролей было видно,
а не приходилось предполагать.

ТОПОЛОГИЯ ЗАДАЁТСЯ РУКАМИ, А НЕ `--cluster-replicas`. Иначе расклады B и C
неповторимы: кто чья реплика, решает утилита, и на втором кластере решение
может быть другим. Здесь слоты раздаются через `CLUSTER ADDSLOTS`, а пары —
через `CLUSTER REPLICATE`, поэтому «тот же расклад» означает буквально тот же.

ЗАПУСК: python3 bench/stretched-cache/cluster.py
Нужны redis-server и redis-cli в PATH (или в REDIS_SERVER_BIN/REDIS_CLI_BIN).
Занимает несколько минут: кластеров поднимается шесть, и каждому надо дать
время на выборы (cluster-node-timeout ниже).
"""

from __future__ import annotations

import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time

import redis

# Какой сервер запускать. По умолчанию тот, что в PATH; REDIS_SERVER_BIN и
# REDIS_CLI_BIN позволяют прогнать тот же замер на другой сборке — так снята
# таблица регрессии на Redis 8.10.1 и smoke-прогон на Valkey.
SERVER = os.environ.get("REDIS_SERVER_BIN", "redis-server")
CLI = os.environ.get("REDIS_CLI_BIN", "redis-cli")

NODE_TIMEOUT_MS = 2000
SETTLE_S = 8.0
SLOT_RANGES = ((0, 5460), (5461, 10922), (10923, 16383))
PROBE = "dc:probe"


def free_port() -> int:
    """Свободный порт НИЖЕ 55535 — и это не придирка.

    В режиме кластера узел слушает два порта: обычный и шинный, ровно на
    десять тысяч больше. Порт из эфемерного диапазона (на этой машине он
    доходит до 60999) даёт шинный порт больше 65535, и узел не поднимается.
    Поймано именно так: замер падал на «узел не поднялся», а в журнале узла
    стояло `Could not create server TCP listening socket`.
    """
    for _ in range(500):
        s = socket.socket()
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
        s.close()
        if port < 55535:
            return port
    raise RuntimeError("не нашлось свободного порта ниже 55535")


def conn(port: int, timeout: float = 2.0) -> redis.Redis:
    return redis.Redis(port=port, socket_timeout=timeout, decode_responses=True)


class Cluster:
    """Шесть узлов с топологией, заданной вызывающим."""

    def __init__(self) -> None:
        self.tmp = tempfile.mkdtemp(prefix="cluster-")
        self.ports: list[int] = []
        self.procs: dict[int, subprocess.Popen] = {}

    def start(self, count: int = 6) -> list[int]:
        for _ in range(count):
            port = free_port()
            d = os.path.join(self.tmp, str(port))
            os.makedirs(d, exist_ok=True)
            proc = subprocess.Popen([
                SERVER,
                "--port", str(port),
                "--dir", d,
                "--cluster-enabled", "yes",
                "--cluster-config-file", f"nodes-{port}.conf",
                "--cluster-node-timeout", str(NODE_TIMEOUT_MS),
                "--save", "",
                "--appendonly", "no",
                "--logfile", os.path.join(d, "redis.log"),
            ])
            deadline = time.time() + 15
            while time.time() < deadline:
                try:
                    c = conn(port, 0.5)
                    if c.ping():
                        c.close()
                        break
                except Exception:
                    time.sleep(0.05)
            else:
                raise RuntimeError(f"узел {port} не поднялся")
            self.ports.append(port)
            self.procs[port] = proc
        return self.ports

    def form(self, pairing: list[tuple[int, int]]) -> None:
        """pairing — список пар (мастер, его реплика), ровно три."""
        masters = [m for m, _ in pairing]
        first = self.ports[0]
        for p in self.ports[1:]:
            conn(first).execute_command("CLUSTER", "MEET", "127.0.0.1", p)
        self._await(lambda: all(
            len(conn(p).execute_command("CLUSTER", "NODES").strip().splitlines()) == len(self.ports)
            for p in self.ports), 30, "узлы не увидели друг друга")

        for port, (lo, hi) in zip(masters, SLOT_RANGES):
            conn(port).execute_command("CLUSTER", "ADDSLOTS", *range(lo, hi + 1))

        ids = {p: conn(p).execute_command("CLUSTER", "MYID") for p in self.ports}
        self._await(lambda: all(
            len(conn(p).execute_command("CLUSTER", "SLOTS")) == 3 for p in self.ports),
            30, "слоты не разошлись по узлам")
        for master, replica in pairing:
            conn(replica).execute_command("CLUSTER", "REPLICATE", ids[master])

        # ЖДЁМ ДВА ПРИЗНАКА, А НЕ ОДИН. `cluster_state:ok` означает, что слоты
        # покрыты; `master_link_status:up` — что реплике есть чем повышаться.
        # Между ними несколько секунд, и мастер, упавший внутри этого окна, не
        # заменяется вовсе. Если ждать только первый признак, замер измеряет
        # это окно, а не расклад.
        self._await(lambda: all(state_of(p) == "ok" for p in self.ports),
                    60, "кластер не собрался")
        replicas = [r for _, r in pairing]
        self._await(lambda: all(
            conn(p).info("replication").get("master_link_status") == "up" for p in replicas),
            60, "реплики не подключились к мастерам")

    @staticmethod
    def _await(cond, timeout: float, message: str) -> None:
        deadline = time.time() + timeout
        while time.time() < deadline:
            try:
                if cond():
                    return
            except Exception:
                pass
            time.sleep(0.2)
        raise RuntimeError(message)

    def stop(self, ports: list[int]) -> None:
        for p in ports:
            subprocess.run(["kill", "-STOP", str(self.procs[p].pid)], check=False)

    def destroy(self) -> None:
        for proc in self.procs.values():
            subprocess.run(["kill", "-CONT", str(proc.pid)], check=False)
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
        shutil.rmtree(self.tmp, ignore_errors=True)


def state_of(port: int) -> str:
    try:
        return conn(port, 1.0).cluster("INFO")["cluster_state"]
    except Exception:
        return "недоступен"


def can_read(port: int, key: str) -> str:
    """Что получает клиент на обычный GET: значение, отказ или молчание."""
    try:
        value = conn(port, 1.5).get(key)
        return "ответил" if value is not None else "ответил (пусто)"
    except redis.exceptions.ClusterDownError:
        return "CLUSTERDOWN"
    except (redis.exceptions.MovedError, redis.exceptions.AskError):
        # Слот не на этом узле. Для нашего вопроса это «жив и отвечает»:
        # клиент кластера пойдёт по указанному адресу.
        return "отдал слот другому узлу"
    except redis.exceptions.ResponseError as exc:
        return str(exc).split()[0]
    except Exception:
        return "таймаут"


def topology(ports: list[int], view_from: int | None = None) -> dict[int, str]:
    """Кто мастер, а кто чья реплика — по идентификаторам узлов кластера."""
    c = conn(view_from if view_from is not None else ports[0])
    raw = c.execute_command("CLUSTER", "NODES")
    by_id: dict[str, int] = {}
    rows = []
    for line in raw.strip().splitlines():
        f = line.split()
        node_id, addr, flags, master_id = f[0], f[1], f[2], f[3]
        port = int(addr.split("@")[0].split(":")[1])
        by_id[node_id] = port
        rows.append((port, flags, master_id))
    out = {}
    for port, flags, master_id in rows:
        if "master" in flags:
            out[port] = "мастер"
        else:
            out[port] = f"реплика {by_id.get(master_id, '?')}"
    return out


def roles(ports: list[int]) -> dict[int, str]:
    out = {}
    for p in ports:
        try:
            out[p] = conn(p, 1.0).info("replication")["role"]
        except Exception:
            out[p] = "?"
    return out


def eligibility(replicas: list[int]) -> None:
    """Право реплики повыситься — то, чего мало кто проверяет заранее.

    Условие из спецификации («большинство мастеров плюс доступная реплика
    каждого потерянного») необходимо, но само по себе не достаточно: реплика
    ещё должна быть достаточно свежей. Свежесть проверяется настройкой
    `cluster-replica-validity-factor`, и здесь печатается то, из чего эта
    проверка считается.
    """
    for p in replicas:
        try:
            c = conn(p)
            info = c.info("replication")
            factor = c.config_get("cluster-replica-validity-factor")
            factor = list(factor.values())[0] if factor else "?"
            print(f"       узел {p}: канал {info.get('master_link_status')}, "
                  f"смещение {info.get('slave_repl_offset')}, "
                  f"validity-factor {factor}")
        except Exception as exc:  # pragma: no cover — диагностика
            print(f"       узел {p}: не опрошен ({exc})")


def observe(label: str, observed: list[int], probe: str) -> None:
    r = roles(observed)
    alive = sum(1 for v in r.values() if v == "master")
    print(f"     {label}: {alive} мастеров из {len(observed)} узлов")
    for p in observed:
        print(f"       узел {p}: состояние {state_of(p):<12} GET -> {can_read(p, probe)}")


def one_side(block_ports_factory, pairing_factory, dc_factory, observed_name: str,
             observed_index: int) -> None:
    """Поднять свежий кластер, оборвать канал и опросить ОДНУ половину."""
    cluster = Cluster()
    try:
        ports = cluster.start(6)
        pairing = pairing_factory(ports)
        cluster.form(pairing)
        dcs = dc_factory(ports, pairing)
        observed, stopped = dcs[observed_index], dcs[1 - observed_index]

        topo = topology(ports)
        print(f"  ── опыт на свежем кластере, наблюдается {observed_name}")
        print(f"     ДЦ-1: {dcs[0]}")
        for p in dcs[0]:
            print(f"       {p}: {topo[p]}")
        print(f"     ДЦ-2: {dcs[1]}")
        for p in dcs[1]:
            print(f"       {p}: {topo[p]}")
        # Кладём настоящий ключ, чтобы «GET -> ответил» означало «отдал
        # данные», а не «слот пуст». Делается ДО опроса реплик: иначе
        # смещение репликации у всех нулевое и смотреть на него бессмысленно.
        cc = redis.RedisCluster(host="127.0.0.1", port=ports[0], decode_responses=True)
        cc.set(probe_key(), "value")
        # Ещё немного ключей, чтобы данные достались всем трём мастерам: иначе
        # у двух реплик из трёх смещение репликации нулевое, и смотреть на него
        # в строке про право повышаться попросту не на что.
        for i in range(20):
            cc.set(f"spread:{i}", i)
        cc.close()
        time.sleep(0.5)

        replicas_here = [p for p in observed if topo[p] != "мастер"]
        if replicas_here:
            print("     право реплик наблюдаемой половины повыситься, до разрыва:")
            eligibility(replicas_here)

        # КАК ИЗОБРАЖАЕТСЯ РАЗРЫВ. Узлы дальней половины останавливаются
        # сигналом STOP. Это не то же самое, что их убить: процесс жив, порт
        # открыт, файл состояния на месте — но узел не отвечает и не шлёт
        # heartbeat. Для оставшейся половины он неотличим от узла за
        # оборванным каналом, а именно это и надо изобразить. Настоящее
        # разделение сети было бы точнее, но требует правил файрвола или
        # сетевых пространств имён, которых у замера может не быть.
        cluster.stop(stopped)
        time.sleep(SETTLE_S)

        observe(observed_name, observed, probe_key())
        after = topology(ports, view_from=observed[0])
        changed = [(p, topo[p], after[p]) for p in observed if topo[p] != after[p]]
        if changed:
            print("     что изменилось в ролях за время разрыва:")
            for p, before, now in changed:
                print(f"       узел {p}: {before} -> {now}")
        else:
            print("     роли на наблюдаемой половине не изменились")
        print()
    finally:
        cluster.destroy()


def probe_key() -> str:
    return PROBE


def pairing_a(ports: list[int]) -> list[tuple[int, int]]:
    """Мастера — первые три, каждая реплика при своём мастере."""
    return [(ports[0], ports[3]), (ports[1], ports[4]), (ports[2], ports[5])]


def dcs_a(ports, pairing):
    return ([ports[0], ports[1], ports[2]], [ports[3], ports[4], ports[5]])


def dcs_b(ports, pairing):
    # ДЦ-1: два мастера и реплика первого. ДЦ-2: третий мастер и его реплика,
    # плюс реплика второго мастера. То есть реплика третьего — в ДЦ-2.
    return ([ports[0], ports[1], ports[3]], [ports[2], ports[4], ports[5]])


def dcs_c(ports, pairing):
    # То же деление по числу узлов, но домой берём реплику ТРЕТЬЕГО мастера.
    return ([ports[0], ports[1], ports[5]], [ports[2], ports[3], ports[4]])


def main() -> None:
    probe = Cluster()
    try:
        probe.start(1)
        version = conn(probe.ports[0]).info("server")["redis_version"]
    finally:
        probe.destroy()

    print(f"Redis {version} · кластер из шести узлов, 3 мастера и 3 реплики")
    print(f"cluster-node-timeout {NODE_TIMEOUT_MS} мс; после разрыва ждём "
          f"{SETTLE_S:.0f} с на выборы")
    print("каждая половина меряется на СВОЁМ кластере с той же топологией")
    print()

    print("4. РАСКЛАД A: ВСЕ МАСТЕРА В ОДНОМ ДЦ, РЕПЛИКИ ВО ВТОРОМ")
    print("-" * 66)
    one_side(None, pairing_a, dcs_a, "ДЦ-1 (мастера)", 0)
    one_side(None, pairing_a, dcs_a, "ДЦ-2 (реплики)", 1)
    print("  ДЦ-1 работает, ДЦ-2 не отвечает ничем. Реплики не стали")
    print("  мастерами: для повышения нужен голос большинства мастеров, а")
    print("  все мастера остались по ту сторону обрыва. То есть вторая")
    print("  половина не запасная — при потере первой она не заменит её.")
    print()

    print("5. РАСКЛАД B: УЗЛЫ ПОРОВНУ, МАСТЕРА ПОДЕЛЕНЫ 2 И 1")
    print("-" * 66)
    one_side(None, pairing_a, dcs_b, "ДЦ-1 (2 мастера)", 0)
    one_side(None, pairing_a, dcs_b, "ДЦ-2 (1 мастер)", 1)
    print("  ОТКАЗАЛИ ОБЕ ПОЛОВИНЫ, и это главный результат замера. Теперь это")
    print("  два независимых опыта на двух свежих кластерах, а не две проверки")
    print("  подряд на одном: вторая половина не могла достаться второму опыту")
    print("  в изменённом виде, потому что первого опыта он не видел.")
    print()
    print("  У ДЦ-1 большинство мастеров, и голосов на повышение реплики")
    print("  ему хватает. Но повышать некого: реплика мастера, оставшегося")
    print("  в ДЦ-2, тоже осталась в ДЦ-2 — смотрите карту выше. Слоты")
    print("  этого мастера не покрыты никем, а кластер по умолчанию")
    print("  отказывается работать с неполным покрытием слотов целиком, а")
    print("  не только по недостающим ключам.")
    print()
    print("  Отсюда правило, ради которого замер и поставлен: большинство")
    print("  голосов необходимо, но НЕ достаточно. Нужна ещё и реплика")
    print("  каждого потерянного мастера на своей стороне обрыва — и она")
    print("  должна быть достаточно свежей, чтобы ей разрешили повышаться.")
    print("  Свежесть в этих опытах не мешала: канал у всех реплик `up` ещё")
    print("  до разрыва, что и напечатано выше. Но условие из спецификации")
    print("  без этой третьей части неполно.")
    print()

    print("6. РАСКЛАД C: ТО ЖЕ ДЕЛЕНИЕ, НО РЕПЛИКА ЧУЖОГО МАСТЕРА ДОМА")
    print("-" * 66)
    one_side(None, pairing_a, dcs_c, "ДЦ-1 (2 мастера + реплика третьего)", 0)
    one_side(None, pairing_a, dcs_c, "ДЦ-2 (1 мастер)", 1)
    print("  Вот теперь ДЦ-1 выживает: и голосов хватило, и было кого")
    print("  повысить. Строка «что изменилось в ролях» показывает это прямо:")
    print("  реплика, привезённая домой, стала мастером.")
    print()
    print("  Но симметрии не появилось — в ДЦ-2 кэша по-прежнему нет. Такой")
    print("  раскладкой выбирают, какой датацентр переживёт разрыв; сделать")
    print("  так, чтобы пережили оба, нельзя.")
    print()
    print("  И ещё одно, ради чего понадобились свежие кластеры. В прежней")
    print("  версии замера обе половины проверялись подряд на одном кластере,")
    print("  и повышение реплики в первой проверке меняло топологию для")
    print("  второй. Здесь этого не может быть по устройству опыта, и разница")
    print("  между блоками 5 и 6 — ровно одна перестановка реплики, а не она")
    print("  же плюс последствия предыдущего опыта.")
    print()


if __name__ == "__main__":
    sys.exit(main())