You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Текущая архитектура gigaevo-core опирается на допущение «один драйвер, один хост». Это допущение проявляется в трёх местах:
Исполнение программ. Пул подпроцессов loky.get_reusable_executor живёт на одной машине с драйвером и завершается вместе с ним.
Память.AmemGamMemory и CardStore используют локальный файловый кэш с записью через os.replace, без идемпотентности на стороне Memory API и без межпроцессной инвалидации.
Блокировка экземпляра.RedisInstanceLock выполняет взаимное исключение и явно запрещает двум драйверам использовать общий префикс ключей в Redis.
При этом в кодовой базе уже есть подсистемы, спроектированные с учётом распределённости: пул серверов LLM (EndpointPool с координацией через атомарный Lua-скрипт в Redis) и шина миграции островов (RedisStreamTransport с XADD/XREAD и захватом сообщений через SETNX). Эти подсистемы доказывают, что Redis-координация при корректной декомпозиции операций обеспечивает достаточную согласованность, и одновременно обнажают тот факт, что аналогичный подход на остальные слои пока не распространён.
Архитектурный фундамент для распределённой топологии поставляется в PR #20 «typed Redis coordination plane». Этот PR вводит единственный типизированный асинхронный координатор gigaevo.dataplane.DataPlane, через который проходят все взаимодействия с Redis. В его составе: восемь атомарных Lua-скриптов, линейные токены владения Token[Tag], типизированный возврат Result[T, E], обёртка потери аренды CrashEvent + wrap_lease и гибридный логический таймштамп HlcTimestamp. Этапы 3, 4 и 5 настоящего RFC прямо опираются на этот субстрат.
Дополнительный фактор - целевая нагрузка смежного проекта kernel-evo: оптимизация CUDA-ядер через эволюционный поиск. Эта нагрузка предъявляет требования, выходящие за рамки текущей поддержки gigaevo-core:
Эксклюзивный доступ к GPU на время измерения: шум измерений недопустим.
Привязка к типу GPU: бенчмарк должен выполняться на конкретной модели (H100, A100, L40S и т. д.).
От сотен до тысяч вариантов ядра в каждом поколении.
Гетерогенный парк: часть рабочих процессов выполняет только компиляцию (CPU), часть проводит измерения (GPU с эксклюзивным захватом).
Текущая локально-замкнутая модель не позволяет эффективно использовать парк из нескольких GPU-узлов: ни один штатный способ запуска драйвера не координирует работу за пределами одного хоста.
Невозможно запустить обработчики на отдельной машине
Без изменений; адресуется этапом 3
Переменные окружения передаются через наследование процесса
os.environ.copy() плюс белый список ключей
Удалённый обработчик не может получить конфигурацию из файловой системы родительского процесса
Без изменений; адресуется этапом 3
Идемпотентность записи в Memory API отсутствует
concept_api._request, POST /v1/memory-cards без ключа идемпотентности
Одновременно работающие драйверы создают дубликаты сущностей
IdempotencyToken + compute_content_hash_hex подготовлены в PR #20; интеграция в Memory API остаётся этапом 4
Один драйвер на префикс ключей
RedisInstanceLock.acquire в gigaevo/database/redis/locking.py
Несколько драйверов вынуждены работать с разными префиксами и не могут вести общий архив
Блокировка экземпляра смигрирована через LuaRegistry.evalsha + Token[InstanceLockTag] + wrap_lease в PR #20; расширение на лидерскую аренду роли производится фабричной функцией поверх существующих Lua-скриптов
CardStore - локальный файл, при конфликте сохраняется последняя запись
На общем диске несколько драйверов перезаписывают индекс друг друга
Относится к плоскости данных; вне круга ответственности DataPlane
MultiModelRouter._verify_models опрашивает серверы при инициализации
gigaevo/llm/models.py:_verify_models
N драйверов одновременно создают нагрузку на инференс-сервер при холодном старте
Без изменений
2.3. Что уже распределено
Подсистема
Координация
Готовность к множеству драйверов
Пул серверов LLM
Атомарный Lua в Redis; ключи llm_pool:{pool}:inflight, :cooldown:{hash}
Полная. Единственная проблема - счётчик inflight не уменьшается при аварийном завершении драйвера
Шина миграции островов
XADD/XREAD; SETNX-захват сообщения с TTL 120 с
Полная
Машина состояний программ
После PR #20: dp.transition_program_state + transition_state.lua с серверной валидацией FSM-таблицы PROGRAM_STATE_TRANSITIONS; идемпотентность через idempotency_token = compute_content_hash_hex(...); поток событий {prefix}:events:status
Согласована внутри одного префикса; межпрефиксная согласованность отнесена к фазе 2 substrate PR #20
Архив элит (MAP-Elites)
После PR #20: dp.try_replace_elite + archive_swap.lua с tiebreak-битом, обратным/прямым индексами и инвариантом биекции
Полная для редуцируемых селекторов (SumArchiveSelector); ParetoFrontSelector сохраняет WATCH-цикл с верхней границей 50 попыток
Реестр многоруких бандитов
После PR #20: dp.crdt_inc + counter_inc.lua с ActorIdentity(run_id, worker_id) для актор-партиционирования; per-arm trial counts и reward sums зеркалируются в Redis G-счётчики
Полная; кросс-процессное разделение состояния
Распределённая блокировка
После PR #20: dp.acquire_instance_lock / renew_instance_lock / release_instance_lock; типизированный Token[InstanceLockTag]; потеря аренды через wrap_lease + CrashEvent
Полная для блокировки префикса; расширение на лидерскую аренду роли и эксклюзивный захват GPU - фабричной функцией поверх существующих Lua-скриптов
После PR #20: dp.crdt_inc для trials/successes/metrics; dp.bounded_list_push для fitness-окна; dp.set_add для каталога метрик; dp.raw_hash_get для опроса engine:total_generations
Полная; межзапусковая агрегация по списку dataplanes: list[DataPlane]
3. Предлагаемая архитектура
Архитектура опирается на четыре принципа:
Redis как единственная плоскость управления. Отдельные серверы очередей, диспетчеров и координаторов не вводятся. Через Redis проходят все формы координации: очереди задач, аренды, пульсы, захваты. Это согласуется с тем, как уже устроены шина миграции и пул серверов LLM.
Разделение плоскости управления и плоскости данных. Через Redis передаются метаданные и ссылки; код, артефакты сборки и результаты измерений хранятся в хранилище артефактов. Локальная файловая система, NFS и S3 выступают взаимозаменяемыми реализациями одной абстракции.
Обработчики как независимые процессы.gigaevo worker оформляется отдельной командой CLI. Жизненный цикл обработчика отвязан от жизненного цикла драйвера. Драйвер становится производителем заявок и потребителем результатов; управление вычислительными ресурсами выносится из драйвера.
Согласование задач и обработчиков через теги. Централизованный планировщик отсутствует; маршрутизация выполняется самими обработчиками через подписку на нужные очереди.
Все четыре принципа реализуются через типизированный субстрат gigaevo.dataplane.DataPlane, поставляемый PR #20. Прямые вызовы redis.asyncio блокированы линтером TID251; любая операция координации проходит через DataPlane.evalsha с заранее зарегистрированным Lua-скриптом; любой возврат имеет форму Result[T, E]; любая аренда оформляется линейным токеном Token[Tag] с дисциплиной однократного потребления.
Распределённая топология - обобщение; локальная топология сохраняется как частный случай. В режиме разработки достаточно одного процесса gigaevo, запущенного с флагом --embedded:
┌───────────────────────────────────────────────────────────────┐
│ Один процесс (gigaevo run --embedded)
│
│ ┌──────────────────┐ ┌─────────────────────────────────┐
│ │ EvolutionEngine │ │ Встроенный пул обработчиков │
│ │ DagRunner │───►│(та же реализация ExecutorBackend│
│ │ │ │ что у gigaevo worker, но в том │
│ │ XADD ──────────► │ │ же процессе и без сетевого │
│ │ in-memory │◄───│ Redis: реализация на asyncio │
│ │ queue │ │ Queue вместо XSTREAM) │
│ └──────────────────┘ └─────────────────────────────────┘
│ │
│ ▼
│ ┌────────────────────────┐
│ │ Хранилище артефактов: │
│ │ file:///tmp/... │ (как сейчас спил-файлы loky)
│ └────────────────────────┘
└───────────────────────────────────────────────────────────────┘
Встроенный режим использует общий контракт ExecutorBackend, реализованный поверх asyncio.Queue вместо Redis Streams. Внешний Redis для разработки и тестирования при этом не требуется. Типизированный контракт обмена (Result[T, E] + Token[Tag] + Versioned[T] из PR #20) обеспечивает инвариантность семантики относительно физического размещения участников, и переключение между распределённой и встроенной топологиями выполняется фабрикой без правок прикладного кода.
3.3. Контракт обработчика
Каждый обработчик реализует короткий рабочий цикл:
Захват продлевается из фоновой задачи. Это снимает противоречие между «короткий TTL, чтобы быстро освобождать после смерти обработчика» и «длинный TTL, чтобы пережить долгую работу». TTL остаётся коротким; обработчик его продлевает, пока работа идёт. После PR #20 реализуется как dp.renew_instance_lock(lease, ttl_s=...); потеря аренды (отказ token-CAS, исключение транспорта) видна типизированно через wrap_lease + CrashEvent, без out-of-band-флагов.
Блокировка GPU действует на время работы. Освобождается в блоке finally. Если процесс убит без блока finally - TTL истечёт и блокировка снимется автоматически. После PR #20 реализуется фабричной функцией поверх dp.acquire_instance_lock с ключом gpu-lock:{node}:{idx}, без модификации Lua-субстрата.
Запись результата строго идемпотентна. Ключ хранилища result/{job_id}/{attempt} уникален; повторная запись с тем же значением attempt либо переписывает байт-в-байт, либо отвергается на стороне хранилища. Координационная сторона идемпотентности обеспечивается IdempotencyToken + compute_content_hash_hex из PR #20.
3.4. Примитивы координации
Используются четыре примитива. Все четыре поставляются в рамках PR #20 как типизированные методы DataPlane поверх соответствующих атомарных Lua-скриптов.
Примитив 1: атомарный захват с TTL. Реализован в scripts/instance_lock_acquire.lua + dp.acquire_instance_lock(key, *, ttl_s, deadline_monotonic) -> Result[InstanceLease, LockHeld].
Возвращает либо линейный токен Token[InstanceLockTag] (через Ok(InstanceLease(...))), либо типизированную ошибку Err(LockHeld(key=..., holder=...)) с идентификатором текущего владельца. Поле holder помечено REDACT_META_KEY для защиты от утечки чувствительных значений через str(err). Используется для назначения задачи (claim:{job_id}), для блокировки GPU (gpu-lock:{node}:{idx}), для эксклюзивной аренды роли (lease:{role}).
Обработчик записывает dp.renew_instance_lock(lease, ttl_s=30) каждые 10 секунд. Соотношение TTL и интервала обновления - 3 : 1 - даёт двойной запас на случай задержек GC, сетевых пауз или пропущенной итерации. Драйвер (или любой наблюдатель) перебирает захваты и проверяет наличие пульса у владельца. Если пульс отсутствует, захват снимается, и задача возвращается в очередь через повторное XADD от стороны, обнаружившей пропажу. Это гарантирует освобождение работы при сбое обработчика в течение не более 30 секунд. На стороне обработчика потеря аренды видна типизированно через CrashEvent без out-of-band-флагов.
Примитив 3: устойчивая очередь на Redis Streams. Словарь подготовлен в PR #20 (substrate фазы 2): NewType-идентификаторы StreamName, ConsumerGroup, ConsumerName, EventId, CausationId, CorrelationId плюс subspace streams/ под Lua-скрипты. Полная реализация dp.emit / dp.consume поверх XREADGROUP / XACK отнесена к фазе 2 общего плана программы изоляции.
Контракт: XADD при отправке, XREAD при чтении, XACK после успешной обработки. Поток имеет ограничение длины через MAXLEN ~. Каждая очередь имеет одну группу потребителей; в неё подписаны все обработчики с подходящими тегами. Это та же схема, что уже используется в RedisStreamTransport. XREAD с блокированием на ~10 секунд даёт реакцию обработчика на новое задание в пределах сетевой задержки, без активного опроса.
Примитив 4: идемпотентная запись. Реализован в PR #20 для FSM-переходов программ: NewType IdempotencyToken + поле idempotency_token = compute_content_hash_hex({"pid": pid, "from": from, "to": to, "patch": patch_dict}, schema_version=1) + ленивая запись через HSETNX в {prefix}:program:{pid}:idem с TTL 300 секунд. Lua-скрипт transition_state.lua проверяет наличие токена через HEXISTS до выполнения мутации; повторный вызов с тем же токеном возвращает кешированный результат предыдущего успешного перехода без побочного эффекта.
В Memory API клиент добавляет заголовок Idempotency-Key: <uuid> (фаза 4 настоящего RFC). Сервер сверяет ключ с короткоживущим журналом (например, Redis-хеш с TTL 24 часа): при совпадении возвращает ранее созданную сущность без повторной обработки, при отсутствии - выполняет операцию и фиксирует ключ. В хранилище артефактов идемпотентность обеспечивается уникальностью пути result/{job_id}/{attempt}: повторная запись с теми же координатами безопасна и не нарушает целостности.
3.5. Модель отказов
Сценарий отказа
Симптом
Восстановление
Аварийное завершение обработчика
Пульс не продлевается
Через ≤30 с TTL пульса истекает, наблюдатель снимает захват, задача возвращается в очередь, идемпотентная запись результата через IdempotencyToken (PR #20) защищает от двойного применения
Сетевая изоляция обработчика
Пульс не доходит до Redis, обработчик жив, но изолирован
То же, что аварийное завершение. При восстановлении dp.renew_instance_lock вернёт Err(LockLost); wrap_lease поднимет CrashEvent; обработчик через match тихо отказывается от записи результата
Аварийное завершение драйвера во время отправки
Часть пакета XADD прошла, часть - нет
Незавершённые задачи отсутствуют в очереди; перезапуск драйвера отправляет недостающие
Сбой первичного Redis
Координация останавливается
Sentinel переводит реплику в первичные; восстановление продолжается; задания в полёте могут выполниться дважды, но защищены IdempotencyToken PR #20
Зависание GPU
Пульс обработчика жив, прогресс отсутствует
Сторожевой таймер на стороне обработчика: если задание не продвинулось за N × ожидаемое время, обработчик принудительно завершает работу и записывает ошибку; внешний супервизор перезапускает обработчика, при необходимости - с nvidia-smi --gpu-reset
Медленный обработчик создаёт блокировку головы очереди
Одна задача удерживает захват, тогда как поколение должно завершиться
Дедлайн поколения короче TTL захвата; при истечении дедлайна драйвер активно снимает захваты повисших задач через dp.release_instance_lock и фиксирует их как неуспех
Атомарные Lua-скрипты в составе EVALSHA с типизированным возвратом Result[T, E] (PR #20)
Гетерогенность парка
Не поддерживается
Через теги обработчиков и подписку на очереди
Эксклюзивный GPU
Не поддерживается явно
Через dp.acquire_instance_lock("gpu-lock:{node}:{idx}", ttl_s=...) с TTL и типизированной потерей через wrap_lease (PR #20)
Обнаружение отказа обработчика
По завершению процесса (только локально)
По истечении пульса (через сеть); типизированный CrashEvent на стороне обработчика (PR #20)
Откат при сбое обработчика
Задача теряется; повторная подача через таймаут
TTL захвата истекает → задача возвращается в очередь автоматически; защищена IdempotencyToken (PR #20)
Несколько драйверов на одном задании
Невозможно (instance lock)
Поддерживается; драйверы - производители и потребители; блокировка экземпляра смигрирована через DataPlane (PR #20)
Защита от двойного применения результата
Отсутствует
IdempotencyToken + compute_content_hash_hex в transition_state.lua (PR #20); заголовок Idempotency-Key в Memory API остаётся этапом 4
Точки отказа
Драйвер, Redis, filesystem, LLM-сервер
Redis, хранилище артефактов, LLM-сервер; драйвер становится восстановимым
4.2. Плюсы
Горизонтальное масштабирование вычислений. Парк обработчиков расширяется по мере добавления хостов без изменений в драйвере.
Изоляция отказов. Сбой одного обработчика не влияет на других. Сбой драйвера не теряет работу: выполненные результаты лежат в results:{driver_id}, незавершённые задачи возвращаются в очередь автоматически.
Типизация по построению. Каждая операция координации возвращает Result[T, E] с исчерпывающим набором ошибочных вариантов; каждая аренда типизирована через линейный токен с дисциплиной однократного потребления; mypy в строгом режиме обнаруживает рассогласования до запуска (PR #20).
Гетерогенный парк. На одном кластере одновременно живут обработчики разной мощности и назначения. Маршрутизация - через теги.
Прямая поддержка измерений CUDA-ядер. Эксклюзивный GPU, привязка по типу, разделение между компиляцией (CPU) и измерением (GPU) - без частных случаев в коде.
Многодрайверный режим без специальных условий. Несколько экспериментов работают одновременно, разделяя один парк обработчиков. Очереди и теги дают естественное разделение.
Совместимость с существующими системами оркестрации. SLURM, Kubernetes, ssh - все одинаково подходят как способ предоставить хосты, на которых живут обработчики. gigaevo не знает о них; они не знают о gigaevo. Точка соединения - переменная окружения GIGAEVO_REDIS_URL.
4.3. Минусы
Усложнённая локальная разработка. Сегодня разработчик запускает python run.py и получает работающий процесс. Новая модель требует поднятого Redis (он и сейчас обязателен) и как минимум одного обработчика в отдельном терминале, либо переключения на встроенный режим. Смягчение: режим --embedded (раздел 3.2) сохраняет однопроцессную модель для тестов и разработки.
Зависимость от хранилища артефактов. Сегодня файлы сброса данных лежат локально. Новая модель требует общедоступного хранилища, видимого и драйверу, и обработчикам. Смягчение: реализация file:// для одного хоста сохраняет текущее поведение; nfs:// и s3:// остаются опциональными расширениями.
Сериализация по сети вместо внутрипроцессного IPC. Cloudpickle через Redis имеет ограниченную пропускную способность, поэтому для задач с большими аргументами или результатами принципиально, чтобы тяжёлые данные шли через плоскость данных, а не через Redis. Смягчение: действует контракт «всё свыше 64 КБ переносится через хранилище артефактов»; в самом Redis остаются только метаданные и ссылки.
Дополнительная эксплуатационная нагрузка. Redis потребует мониторинга, резервного копирования и, по необходимости, конфигурации высокой доступности (Sentinel или Cluster). Хранилище артефактов также нуждается во внимании. Смягчение: до уровня примерно 100 обработчиков и 50 тыс. операций в секунду одиночный экземпляр Redis работает надёжно и без дополнительных слоёв.
Сложность отладки распределённых сценариев. Поток исполнения теперь пересекает сетевые границы, и интерактивный отладчик здесь бессилен. Смягчение: структурированные логи через loguru с тегами worker_id и node_id (задача L-D2); все ключевые события Redis (захват, освобождение, истечение TTL) пишутся в наблюдаемый поток events:audit.
5. Этапы внедрения
Архитектура внедряется инкрементально. Каждый этап оставляет систему в работающем состоянии и приносит измеримую пользу.
Этап 0. Готовое состояние (после PR #17).
Сетевой слой переписан, прямой httpx из кодовой базы исключён, опубликован стабильный фундамент для асинхронных HTTP-вызовов.
Этап 1. Подготовительная гигиена (выполнено в PR #14).
Идентификация обработчиков: node_id из socket.gethostname() (либо GIGAEVO_EXECUTOR_NODE_ID), worker_id через uuid4. Оба идентификатора пробрасываются в окружение обработчика (GIGAEVO_NODE_ID, GIGAEVO_WORKER_ID) и в каждую запись ExecutionMetrics, прикреплённую к результату вызова.
Точки расширения жизненного цикла на уровне вызова: on_submit, on_complete, on_shutdown, вынесены в отдельный опциональный протокол LifecycleObservable. Хуки уровня процесса-обработчика (on_worker_start, on_worker_idle, on_worker_exit) отложены до Этапа 3, когда появится сетевой источник этих событий.
Этап 2. Абстракция исполнителя (выполнено в PR #14).
Минимальный интерфейс ExecutorBackend с двумя методами: execute и shutdown. Наблюдение жизненного цикла вынесено в отдельный опциональный протокол LifecycleObservable; реализации без полезных событий не обязаны добавлять заглушки. recycle_worker отложен: вводится одновременно с механизмом принудительного перезапуска обработчика по счётчику вызовов.
Реализация LokyBackend соответствует обоим протоколам и сохраняет существующее поведение loky (ленивое создание пула, изолированные spill-каталоги, PR_SET_PDEATHSIG на Linux, скрабинг окружения по белому списку + префиксам).
Точки вызова (runtime_metrics.py, optimization/utils.py, optimization/optuna/stage.py, execution.py) продолжают использовать run_exec_runner без изменений; добавленный кварг backend= позволяет внедрить любой ExecutorBackend без правки вызывающего кода. Singleton по умолчанию (default_loky_backend()) сохраняет совместимость.
Этап 2.5. Координационный субстрат DataPlane (выполнено в PR #20).
gigaevo.dataplane.DataPlane: единственный типизированный асинхронный координатор для всех взаимодействий с Redis.
Восемь атомарных Lua-скриптов: transition_state, archive_swap, instance_lock_acquire/renew/release, counter_inc, lwwr_set, bounded_list_push.
Линейные токены Token[Tag] с mint_root / mint_split / mint_split_n, EngineRoot для разметки подпространств.
Типизированный возврат Result[T, E] с дискриминированной иерархией DataPlaneError (19 структурированных типов исключений).
Обёртка потерянного владения wrap_lease + CrashWatchedHandle + OneShotFlag + CrashEvent: потеря аренды видна типизированно через match, без out-of-band-флагов.
Гибридный логический таймштамп HlcTimestamp с лексикографически сравнимой hex-упаковкой.
NewType-словарь для фазы 3: StreamName, ConsumerGroup, ConsumerName, EventId, CausationId, CorrelationId, IdempotencyToken, NodeId плюс пять других идентификаторов.
Ограничение глубины очереди (мягкий backpressure через периодический опрос XLEN).
Сторожевой таймер для застрявших задач.
Кросс-драйверная инвалидация кеша Memory API через Redis pub/sub.
Этап 5. Особенности гетерогенного парка.
Теги обработчиков и подписка на очереди по тегам.
Эксклюзивный захват GPU через фабричную функцию поверх dp.acquire_instance_lock с ключом gpu-lock:{node}:{idx}; без модификации Lua-субстрата (поставлено в PR #20).
Альтернативная реализация ArtifactStore для NFS / S3.
После Этапа 2 (интерфейс ExecutorBackend) и Этапа 2.5 (DataPlane substrate) меняются следующие места:
После PR #20: использует build_dataplane, build_engine_root, build_actor_identity плюс семейство wire_* для пост-instantiate-привязки координатора. Этап 3 добавляет инициализацию RedisStreamWorkerBackend на основе конфигурации Hydra
tests/conftest.py
Использует LokyBackend или встроенный backend в зависимости от теста
Существующие точки вызова не меняются. Архитектурно изменяется только то, как run_exec_runner внутри себя выбирает реализацию, и какой координатор обслуживает прямые взаимодействия с Redis.
Возможное пространство ключей Redis
Ключ или поток
Тип
TTL
Назначение
tasks:{tag}
Stream
бессрочно (MAXLEN)
Очередь задач, сегментированная по типу обработчика
results:{driver_id}
Stream
бессрочно (MAXLEN)
Результаты, адресованные конкретному драйверу
evo:migrate
Stream
бессрочно (MAXLEN)
Миграция программ между островами (уже существует)
{prefix}:events:status
Stream
бессрочно (MAXLEN ~ 10000)
Поток событий FSM-переходов программ (через transition_state.luaPR #20)
events:audit
Stream
бессрочно (MAXLEN)
Аудит-лог операций координации
claim:{job_id}
String
равен deadline_s задачи
Захват задачи обработчиком (через dp.acquire_instance_lockPR #20), продлеваемый
heartbeat:{worker_id}
String
30 с
Пульс обработчика, продлеваемый каждые 10 с (через dp.renew_instance_lock)
gpu-lock:{node}:{idx}
String
равен deadline_s задачи
Эксклюзивный захват GPU (фабричная функция поверх dp.acquire_instance_lock)
lease:{role}
String
равен интервалу продления × 3
Лидерская аренда роли (например, lease:gam-rebuilder); фабричная функция поверх Lua-скриптов PR #20
idem:{op_id}
String
24 ч
Журнал идемпотентности Memory API (фаза 4)
{prefix}:program:{pid}:idem
Hash
300 с
Журнал идемпотентности FSM-переходов программ (через IdempotencyTokenPR #20)
{prefix}:fsm:program_state
Hash
бессрочно
FSM-таблица, загружаемая в Redis на dp.startup() (PR #20)
{prefix}:program:{pid}
String
бессрочно
JSON-блоб программы, мутируемый только через dp.transition_program_state (PR #20)
{prefix}:status:{state}
Set
бессрочно
Множества программ по состоянию, обновляемые атомарно вместе с FSM-переходом (PR #20)
{prefix}:ts
String
бессрочно
Счётчик эпох, инкрементируемый атомарно при каждом FSM-переходе (PR #20)
1. Status quo
Текущая архитектура
gigaevo-coreопирается на допущение «один драйвер, один хост». Это допущение проявляется в трёх местах:loky.get_reusable_executorживёт на одной машине с драйвером и завершается вместе с ним.AmemGamMemoryиCardStoreиспользуют локальный файловый кэш с записью черезos.replace, без идемпотентности на стороне Memory API и без межпроцессной инвалидации.RedisInstanceLockвыполняет взаимное исключение и явно запрещает двум драйверам использовать общий префикс ключей в Redis.При этом в кодовой базе уже есть подсистемы, спроектированные с учётом распределённости: пул серверов LLM (
EndpointPoolс координацией через атомарный Lua-скрипт в Redis) и шина миграции островов (RedisStreamTransportс XADD/XREAD и захватом сообщений через SETNX). Эти подсистемы доказывают, что Redis-координация при корректной декомпозиции операций обеспечивает достаточную согласованность, и одновременно обнажают тот факт, что аналогичный подход на остальные слои пока не распространён.Архитектурный фундамент для распределённой топологии поставляется в PR #20 «typed Redis coordination plane». Этот PR вводит единственный типизированный асинхронный координатор
gigaevo.dataplane.DataPlane, через который проходят все взаимодействия с Redis. В его составе: восемь атомарных Lua-скриптов, линейные токены владенияToken[Tag], типизированный возвратResult[T, E], обёртка потери арендыCrashEvent+wrap_leaseи гибридный логический таймштампHlcTimestamp. Этапы 3, 4 и 5 настоящего RFC прямо опираются на этот субстрат.Дополнительный фактор - целевая нагрузка смежного проекта
kernel-evo: оптимизация CUDA-ядер через эволюционный поиск. Эта нагрузка предъявляет требования, выходящие за рамки текущей поддержкиgigaevo-core:Текущая локально-замкнутая модель не позволяет эффективно использовать парк из нескольких GPU-узлов: ни один штатный способ запуска драйвера не координирует работу за пределами одного хоста.
2. Текущая архитектура
2.1. Топология «один хост по умолчанию»
2.2. Жёсткие ограничения для распределения
gigaevo/programs/stages/python_executors/wrapper.py,asyncio.create_subprocess_execos.environ.copy()плюс белый список ключейconcept_api._request,POST /v1/memory-cardsбез ключа идемпотентностиIdempotencyToken+compute_content_hash_hexподготовлены в PR #20; интеграция в Memory API остаётся этапом 4RedisInstanceLock.acquireвgigaevo/database/redis/locking.pyLuaRegistry.evalsha+Token[InstanceLockTag]+wrap_leaseв PR #20; расширение на лидерскую аренду роли производится фабричной функцией поверх существующих Lua-скриптовCardStore- локальный файл, при конфликте сохраняется последняя записьgigaevo/memory/shared_memory/card_store.py,os.replaceMultiModelRouter._verify_modelsопрашивает серверы при инициализацииgigaevo/llm/models.py:_verify_models2.3. Что уже распределено
llm_pool:{pool}:inflight,:cooldown:{hash}dp.transition_program_state+transition_state.luaс серверной валидацией FSM-таблицыPROGRAM_STATE_TRANSITIONS; идемпотентность черезidempotency_token = compute_content_hash_hex(...); поток событий{prefix}:events:statusdp.try_replace_elite+archive_swap.luaс tiebreak-битом, обратным/прямым индексами и инвариантом биекцииSumArchiveSelector);ParetoFrontSelectorсохраняет WATCH-цикл с верхней границей 50 попытокdp.crdt_inc+counter_inc.luaсActorIdentity(run_id, worker_id)для актор-партиционирования; per-arm trial counts и reward sums зеркалируются в Redis G-счётчикиdp.acquire_instance_lock/renew_instance_lock/release_instance_lock; типизированныйToken[InstanceLockTag]; потеря аренды черезwrap_lease+CrashEventprompts/fetcher,coevolution/stats)dp.crdt_incдля trials/successes/metrics;dp.bounded_list_pushдля fitness-окна;dp.set_addдля каталога метрик;dp.raw_hash_getдля опросаengine:total_generationsdataplanes: list[DataPlane]3. Предлагаемая архитектура
Архитектура опирается на четыре принципа:
gigaevo workerоформляется отдельной командой CLI. Жизненный цикл обработчика отвязан от жизненного цикла драйвера. Драйвер становится производителем заявок и потребителем результатов; управление вычислительными ресурсами выносится из драйвера.Все четыре принципа реализуются через типизированный субстрат
gigaevo.dataplane.DataPlane, поставляемый PR #20. Прямые вызовыredis.asyncioблокированы линтеромTID251; любая операция координации проходит черезDataPlane.evalshaс заранее зарегистрированным Lua-скриптом; любой возврат имеет формуResult[T, E]; любая аренда оформляется линейным токеномToken[Tag]с дисциплиной однократного потребления.3.1. Целевая топология (распределённый режим)
3.2. Встроенный режим (для разработки и тестов)
Распределённая топология - обобщение; локальная топология сохраняется как частный случай. В режиме разработки достаточно одного процесса
gigaevo, запущенного с флагом--embedded:Встроенный режим использует общий контракт
ExecutorBackend, реализованный поверхasyncio.Queueвместо Redis Streams. Внешний Redis для разработки и тестирования при этом не требуется. Типизированный контракт обмена (Result[T, E]+Token[Tag]+Versioned[T]из PR #20) обеспечивает инвариантность семантики относительно физического размещения участников, и переключение между распределённой и встроенной топологиями выполняется фабрикой без правок прикладного кода.3.3. Контракт обработчика
Каждый обработчик реализует короткий рабочий цикл:
dp.renew_instance_lock(lease, ttl_s=...); потеря аренды (отказ token-CAS, исключение транспорта) видна типизированно черезwrap_lease+CrashEvent, без out-of-band-флагов.finally. Если процесс убит без блокаfinally- TTL истечёт и блокировка снимется автоматически. После PR #20 реализуется фабричной функцией поверхdp.acquire_instance_lockс ключомgpu-lock:{node}:{idx}, без модификации Lua-субстрата.result/{job_id}/{attempt}уникален; повторная запись с тем же значениемattemptлибо переписывает байт-в-байт, либо отвергается на стороне хранилища. Координационная сторона идемпотентности обеспечиваетсяIdempotencyToken+compute_content_hash_hexиз PR #20.3.4. Примитивы координации
Используются четыре примитива. Все четыре поставляются в рамках PR #20 как типизированные методы
DataPlaneповерх соответствующих атомарных Lua-скриптов.Примитив 1: атомарный захват с TTL. Реализован в
scripts/instance_lock_acquire.lua+dp.acquire_instance_lock(key, *, ttl_s, deadline_monotonic) -> Result[InstanceLease, LockHeld].Возвращает либо линейный токен
Token[InstanceLockTag](черезOk(InstanceLease(...))), либо типизированную ошибкуErr(LockHeld(key=..., holder=...))с идентификатором текущего владельца. ПолеholderпомеченоREDACT_META_KEYдля защиты от утечки чувствительных значений черезstr(err). Используется для назначения задачи (claim:{job_id}), для блокировки GPU (gpu-lock:{node}:{idx}), для эксклюзивной аренды роли (lease:{role}).Примитив 2: продлеваемый пульс. Реализован в
scripts/instance_lock_renew.lua(token-CAS обновление TTL) +dp.renew_instance_lock(lease, ttl_s) -> Result[InstanceLease, LockLost]+dp.wrap_lease(lease) -> CrashWatchedHandle.Обработчик записывает
dp.renew_instance_lock(lease, ttl_s=30)каждые 10 секунд. Соотношение TTL и интервала обновления - 3 : 1 - даёт двойной запас на случай задержек GC, сетевых пауз или пропущенной итерации. Драйвер (или любой наблюдатель) перебирает захваты и проверяет наличие пульса у владельца. Если пульс отсутствует, захват снимается, и задача возвращается в очередь через повторное XADD от стороны, обнаружившей пропажу. Это гарантирует освобождение работы при сбое обработчика в течение не более 30 секунд. На стороне обработчика потеря аренды видна типизированно черезCrashEventбез out-of-band-флагов.Примитив 3: устойчивая очередь на Redis Streams. Словарь подготовлен в PR #20 (substrate фазы 2): NewType-идентификаторы
StreamName,ConsumerGroup,ConsumerName,EventId,CausationId,CorrelationIdплюс subspacestreams/под Lua-скрипты. Полная реализацияdp.emit/dp.consumeповерхXREADGROUP/XACKотнесена к фазе 2 общего плана программы изоляции.Контракт: XADD при отправке, XREAD при чтении, XACK после успешной обработки. Поток имеет ограничение длины через
MAXLEN ~. Каждая очередь имеет одну группу потребителей; в неё подписаны все обработчики с подходящими тегами. Это та же схема, что уже используется вRedisStreamTransport. XREAD с блокированием на ~10 секунд даёт реакцию обработчика на новое задание в пределах сетевой задержки, без активного опроса.Примитив 4: идемпотентная запись. Реализован в PR #20 для FSM-переходов программ: NewType
IdempotencyToken+ полеidempotency_token = compute_content_hash_hex({"pid": pid, "from": from, "to": to, "patch": patch_dict}, schema_version=1)+ ленивая запись черезHSETNXв{prefix}:program:{pid}:idemс TTL 300 секунд. Lua-скриптtransition_state.luaпроверяет наличие токена черезHEXISTSдо выполнения мутации; повторный вызов с тем же токеном возвращает кешированный результат предыдущего успешного перехода без побочного эффекта.В Memory API клиент добавляет заголовок
Idempotency-Key: <uuid>(фаза 4 настоящего RFC). Сервер сверяет ключ с короткоживущим журналом (например, Redis-хеш с TTL 24 часа): при совпадении возвращает ранее созданную сущность без повторной обработки, при отсутствии - выполняет операцию и фиксирует ключ. В хранилище артефактов идемпотентность обеспечивается уникальностью путиresult/{job_id}/{attempt}: повторная запись с теми же координатами безопасна и не нарушает целостности.3.5. Модель отказов
IdempotencyToken(PR #20) защищает от двойного примененияdp.renew_instance_lockвернётErr(LockLost);wrap_leaseподниметCrashEvent; обработчик черезmatchтихо отказывается от записи результатаIdempotencyTokenPR #20nvidia-smi --gpu-resetdp.release_instance_lockи фиксирует их как неуспехIdempotency-Key; сервер возвращает существующийentity_idattemptattempt; при исчерпании лимита попыток драйвер видит окончательный неуспехDISCARDED, запись отменяется4. Сравнение с текущей моделью
4.1. Сводная таблица
os.environс фильтрациейWorkerConfig, доставляется как параметры запуска или через RedisWorkerCall(cloudpickle)dp.acquire_instance_lock(PR #20)redis.asyncioвызовы во многих точкахgigaevo.dataplane.DataPlane(PR #20); прямые импортыredisблокированы линтеромTID251WATCH/MULTI/EXECбез верхней границыEVALSHAс типизированным возвратомResult[T, E](PR #20)dp.acquire_instance_lock("gpu-lock:{node}:{idx}", ttl_s=...)с TTL и типизированной потерей черезwrap_lease(PR #20)CrashEventна стороне обработчика (PR #20)IdempotencyToken(PR #20)IdempotencyToken+compute_content_hash_hexвtransition_state.lua(PR #20); заголовокIdempotency-Keyв Memory API остаётся этапом 44.2. Плюсы
results:{driver_id}, незавершённые задачи возвращаются в очередь автоматически.Result[T, E]с исчерпывающим набором ошибочных вариантов; каждая аренда типизирована через линейный токен с дисциплиной однократного потребления; mypy в строгом режиме обнаруживает рассогласования до запуска (PR #20).ssh- все одинаково подходят как способ предоставить хосты, на которых живут обработчики.gigaevoне знает о них; они не знают оgigaevo. Точка соединения - переменная окруженияGIGAEVO_REDIS_URL.4.3. Минусы
python run.pyи получает работающий процесс. Новая модель требует поднятого Redis (он и сейчас обязателен) и как минимум одного обработчика в отдельном терминале, либо переключения на встроенный режим. Смягчение: режим--embedded(раздел 3.2) сохраняет однопроцессную модель для тестов и разработки.file://для одного хоста сохраняет текущее поведение;nfs://иs3://остаются опциональными расширениями.worker_idиnode_id(задача L-D2); все ключевые события Redis (захват, освобождение, истечение TTL) пишутся в наблюдаемый потокevents:audit.5. Этапы внедрения
Архитектура внедряется инкрементально. Каждый этап оставляет систему в работающем состоянии и приносит измеримую пользу.
Этап 0. Готовое состояние (после PR #17).
Сетевой слой переписан, прямой
httpxиз кодовой базы исключён, опубликован стабильный фундамент для асинхронных HTTP-вызовов.Этап 1. Подготовительная гигиена (выполнено в PR #14).
WorkerConfig: явная сериализуемая конфигурация обработчика (frozen dataclass, slots).from_envраспознаётGIGAEVO_EXECUTOR_MAX_WORKERS,GIGAEVO_EXECUTOR_IDLE_TIMEOUT_S,GIGAEVO_EXECUTOR_SPILL_DIR,GIGAEVO_EXECUTOR_NODE_IDиGIGAEVO_EXECUTOR_DISABLE_PDEATHSIG.node_idизsocket.gethostname()(либоGIGAEVO_EXECUTOR_NODE_ID),worker_idчерезuuid4. Оба идентификатора пробрасываются в окружение обработчика (GIGAEVO_NODE_ID,GIGAEVO_WORKER_ID) и в каждую записьExecutionMetrics, прикреплённую к результату вызова.on_submit,on_complete,on_shutdown, вынесены в отдельный опциональный протоколLifecycleObservable. Хуки уровня процесса-обработчика (on_worker_start,on_worker_idle,on_worker_exit) отложены до Этапа 3, когда появится сетевой источник этих событий.Этап 2. Абстракция исполнителя (выполнено в PR #14).
ExecutorBackendс двумя методами:executeиshutdown. Наблюдение жизненного цикла вынесено в отдельный опциональный протоколLifecycleObservable; реализации без полезных событий не обязаны добавлять заглушки.recycle_workerотложен: вводится одновременно с механизмом принудительного перезапуска обработчика по счётчику вызовов.LokyBackendсоответствует обоим протоколам и сохраняет существующее поведениеloky(ленивое создание пула, изолированные spill-каталоги,PR_SET_PDEATHSIGна Linux, скрабинг окружения по белому списку + префиксам).runtime_metrics.py,optimization/utils.py,optimization/optuna/stage.py,execution.py) продолжают использоватьrun_exec_runnerбез изменений; добавленный кваргbackend=позволяет внедрить любойExecutorBackendбез правки вызывающего кода. Singleton по умолчанию (default_loky_backend()) сохраняет совместимость.Этап 2.5. Координационный субстрат DataPlane (выполнено в PR #20).
gigaevo.dataplane.DataPlane: единственный типизированный асинхронный координатор для всех взаимодействий с Redis.transition_state,archive_swap,instance_lock_acquire/renew/release,counter_inc,lwwr_set,bounded_list_push.Token[Tag]сmint_root/mint_split/mint_split_n,EngineRootдля разметки подпространств.Result[T, E]с дискриминированной иерархиейDataPlaneError(19 структурированных типов исключений).wrap_lease+CrashWatchedHandle+OneShotFlag+CrashEvent: потеря аренды видна типизированно черезmatch, без out-of-band-флагов.HlcTimestampс лексикографически сравнимой hex-упаковкой.StreamName,ConsumerGroup,ConsumerName,EventId,CausationId,CorrelationId,IdempotencyToken,NodeIdплюс пять других идентификаторов.ruff TID251блокирует прямые импортыredis/redis.asyncio/redis.exceptionsвне субстрата; AST-инспектор отвергает неаннотированные широкиеexceptвнутри координатора.try_replace_elite+archive_swap.lua), реестр многоруких бандитов (crdt_inc+ actor-партиционирование), словарь подсказок (bounded_list_push,crdt_inc,set_add), распределённая блокировка экземпляра (acquire/renew/release_instance_lock+wrap_lease), миграционная шина (частично; полное закрытие отложено в фазу 2).Этап 3. Сетевая очередь задач.
RedisStreamWorkerBackendповерхdp.emit/dp.consumeс использованием существующих в PR #20 NewType-идентификаторов (StreamName,ConsumerGroup,EventId); полная реализацияconsumeповерхXREADGROUP/XACKотнесена к фазе 2 substrate настоящего RFC.ArtifactStoreс реализациейfile://(поведение совпадает с текущими файлами сброса данных).gigaevo worker.--embeddedс реализациейExecutorBackendповерхasyncio.Queue; контракт обмена (Result[T, E]+Token[Tag]+Versioned[T]из PR feat(dataplane): typed Redis coordination plane with atomic Lua substrate, FSM migration, and Hydra hardening #20) инвариантен относительно физического размещения участников.Этап 4. Усиление надёжности.
dp.renew_instance_lock+wrap_lease(примитив уже доступен через PR #20).Idempotency-Key(требует согласованных изменений на стороне сервера); координационная сторона уже располагаетIdempotencyToken+compute_content_hash_hexиз PR feat(dataplane): typed Redis coordination plane with atomic Lua substrate, FSM migration, and Hydra hardening #20.XLEN).Этап 5. Особенности гетерогенного парка.
dp.acquire_instance_lockс ключомgpu-lock:{node}:{idx}; без модификации Lua-субстрата (поставлено в PR #20).ArtifactStoreдля NFS / S3.После Этапа 2 (интерфейс
ExecutorBackend) и Этапа 2.5 (DataPlane substrate) меняются следующие места:gigaevo/programs/stages/python_executors/wrapper.run_exec_runnerExecutorBackend.submit. Сохраняет существующую сигнатуру для обратной совместимостиgigaevo/programs/stages/runtime_metrics.pyrun_exec_runnergigaevo/programs/stages/optimization/utils.pygigaevo/programs/stages/optimization/optuna/stage.pygigaevo/programs/stages/python_executors/execution.pyrun.pybuild_dataplane,build_engine_root,build_actor_identityплюс семействоwire_*для пост-instantiate-привязки координатора. Этап 3 добавляет инициализациюRedisStreamWorkerBackendна основе конфигурации Hydratests/conftest.pyLokyBackendили встроенный backend в зависимости от тестаСуществующие точки вызова не меняются. Архитектурно изменяется только то, как
run_exec_runnerвнутри себя выбирает реализацию, и какой координатор обслуживает прямые взаимодействия с Redis.Возможное пространство ключей Redis
tasks:{tag}results:{driver_id}evo:migrate{prefix}:events:statustransition_state.luaPR #20)events:auditclaim:{job_id}dp.acquire_instance_lockPR #20), продлеваемыйheartbeat:{worker_id}dp.renew_instance_lock)gpu-lock:{node}:{idx}dp.acquire_instance_lock)lease:{role}lease:gam-rebuilder); фабричная функция поверх Lua-скриптов PR #20idem:{op_id}{prefix}:program:{pid}:idemIdempotencyTokenPR #20){prefix}:fsm:program_statedp.startup()(PR #20){prefix}:program:{pid}dp.transition_program_state(PR #20){prefix}:status:{state}{prefix}:ts{prefix}:archive/:archive:reverse/:archive:scoresdp.try_replace_elite+archive_swap.lua(PR #20){prefix}:counter:{key}dp.crdt_inc+counter_inc.lua(PR #20){prefix}:lwwr:{key}dp.lwwr_set+lwwr_set.lua(substrate фазы 2 PR #20)instance-lock:{prefix}dp.acquire_instance_lock(PR #20 §9.4)llm_pool:{name}:inflightllm_pool:{name}:cooldown:{hash}Все потоки ограничены по длине через
MAXLEN ~ N, где N подбирается под ожидаемую глубину очереди для соответствующего тега.