From 97b13a13c2b390c9a13b1acde1ac92d51d03b832 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sun, 26 Apr 2026 09:53:06 +0300 Subject: [PATCH] doc: add namespace manager target design --- ...6-04-26-namespace-manager-target-design.md | 697 ++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 doc/thinking/2026-04-26-namespace-manager-target-design.md diff --git a/doc/thinking/2026-04-26-namespace-manager-target-design.md b/doc/thinking/2026-04-26-namespace-manager-target-design.md new file mode 100644 index 00000000..e9250226 --- /dev/null +++ b/doc/thinking/2026-04-26-namespace-manager-target-design.md @@ -0,0 +1,697 @@ +# 2026-04-26 — Target design: полноценный NamespaceManager для Layer 1 + +## Зачем нужен ещё один документ + +Уже есть подробный документ про сделанные шаги 1-6. +Но этого недостаточно для следующего этапа, потому что: + +1. История исправлений не равна целевой архитектуре. +2. Локальные фиксы уже уменьшили риск, но не дали единого lifecycle contract. +3. Следующий этап уже нельзя начинать как серию хаотичных патчей по месту. + +Нужен отдельный документ, который отвечает на вопрос: + +какой именно Layer 1 мы хотим получить в результате bounded rewrite. + +--- + +## Коротко: что именно строим + +Нужен не просто thread-safe registry namespace-ов, а orchestration layer с явным lifecycle. + +То есть не объект вида: + +- `map[string]string` + `AddNamespace()` + +а объект вида: + +- обнаружение namespace; +- нормализация состояния; +- единый жизненный цикл add/remove/reconcile; +- подписка компонентов на события; +- безопасный snapshot для background loops; +- backfill existing namespaces on startup; +- восстановление после restart. + +Рабочее имя этой сущности: `NamespaceManager`. + +--- + +## Какую проблему он решает + +Сейчас логика размазана по нескольким слоям одновременно: + +1. `NamespaceResolver` хранит registry. +2. watcher-ы executor/router/buildermgr сами решают, как регистрировать namespace. +3. components сами придумывают свой dedup. +4. часть background loops читают namespace snapshot. +5. provisioning SA/RBAC живёт как side effect watcher-а. + +Из-за этого нет одного ответа на вопросы: + +1. Когда namespace считается «принятым» системой? +2. Когда он считается «удалённым»? +3. Что должно происходить при restart компонента? +4. Кто отвечает за cleanup? +5. Кто отвечает за reconcile при расхождении локального и фактического состояния? + +`NamespaceManager` нужен именно для того, чтобы эти вопросы получили один общий ответ. + +--- + +## Какие свойства должны быть у новой подсистемы + +### 1. Один вход для namespace lifecycle + +Все namespace-ы, независимо от того, пришли они: + +- из env на старте; +- из уже существующих labeled namespaces; +- из нового namespace event; +- из relabel existing namespace; + +должны проходить через один и тот же pipeline. + +### 2. Явный state machine + +Нельзя больше жить в модели «namespace либо есть в map, либо нет». Нужны как минимум фазы: + +- discovered; +- registering; +- active; +- deregistering; +- removed; +- failed. + +Не обязательно все эти фазы сразу экспонировать наружу, но внутренняя модель должна понимать, на каком этапе lifecycle находится namespace. + +### 3. Разделение ответственности + +Нужно развести по слоям: + +1. Discovery — кто узнал о namespace. +2. Registry — текущее состояние namespace в памяти процесса. +3. Reconcile — как довести локальное состояние до желаемого. +4. Subscription — как сообщить executor/router/buildermgr о событии. +5. Provisioning — отдельные side effects вроде SA/RBAC. + +### 4. Thread-safe чтение и запись + +Любой компонент должен иметь один безопасный способ получить: + +- snapshot namespace-ов; +- текущее состояние конкретного namespace; +- stream событий. + +### 5. Symmetry startup vs runtime + +Если namespace был известен на старте или пришёл позже, конечный набор действий должен быть одинаковым. + +Именно этот пункт был нарушен в `newdeploy`, и именно он должен стать жёстким архитектурным правилом нового дизайна. + +--- + +## Что не должно быть в новой модели + +### 1. Прямых чтений глобальной map из произвольных мест + +Любой код, который напрямую читает внутреннюю структуру namespace registry, должен считаться legacy и подлежать выносу. + +### 2. Глобального dedup вместо локального lifecycle + +Global registry отвечает только на вопрос «namespace известен системе». Он не должен автоматически означать «каждый компонент уже подключил все свои informers». + +### 3. Неявных side effects в watcher callback + +Watcher должен сообщать о факте, а не выполнять пол-процесса orchestration сам по себе. + +### 4. Скрытой зависимости от порядка вызовов + +Сейчас уже был пойман дефект, когда второй компонент не регистрировался, потому что первый успел пометить namespace как «уже обработанный». Новая модель должна быть инвариантна к порядку subscriber-ов. + +--- + +## Предлагаемая модель данных + +Ниже не обязательно точный конечный код, но это целевая форма. + +```go +type NamespacePhase string + +const ( + NamespacePhaseDiscovered NamespacePhase = "discovered" + NamespacePhaseRegistering NamespacePhase = "registering" + NamespacePhaseActive NamespacePhase = "active" + NamespacePhaseDeregistering NamespacePhase = "deregistering" + NamespacePhaseRemoved NamespacePhase = "removed" + NamespacePhaseFailed NamespacePhase = "failed" +) + +type NamespaceRecord struct { + Name string + Source NamespaceSource + Labels map[string]string + Phase NamespacePhase + LastError string + Generation int64 + UpdatedAt time.Time + RegisteredParts map[string]NamespacePartState +} + +type NamespacePartState struct { + State string + LastError string + UpdatedAt time.Time +} +``` + +Важная идея: manager должен знать не только список namespace-ов, но и состояние регистрации по частям. + +Например: + +- executor.poolmgr: active +- executor.newdeploy: active +- router: active +- buildermgr.env: active +- buildermgr.pkg: failed +- provisioning.fetcher-sa: active + +Это критично для reconcile. Иначе при частичном падении система знает только «namespace есть», но не знает, что именно недорегистрировано. + +--- + +## Источники namespace-ов + +Нужен явный тип источника, чтобы не смешивать namespace-ы с разным происхождением. + +```go +type NamespaceSource string + +const ( + NamespaceSourceEnv NamespaceSource = "env" + NamespaceSourceWatcher NamespaceSource = "watcher" + NamespaceSourceBackfill NamespaceSource = "backfill" +) +``` + +Почему это важно: + +1. Проще расследовать состояние системы. +2. Проще логировать, откуда namespace попал в менеджер. +3. Проще понять, что именно должно переживать restart и что должно исчезать при relabel/delete. + +--- + +## Предлагаемый API NamespaceManager + +Ниже не «идеальный forever API», а минимально полезный контракт. + +```go +type NamespaceManager interface { + Snapshot() []string + SnapshotRecords() []NamespaceRecord + Get(name string) (NamespaceRecord, bool) + + RegisterDesired(ctx context.Context, event NamespaceEvent) error + DeregisterDesired(ctx context.Context, name string, reason string) error + + Subscribe(name string, subscriber NamespaceSubscriber) + Start(ctx context.Context) +} +``` + +И ещё важнее — не только sync API, но и события. + +```go +type NamespaceEventType string + +const ( + NamespaceEventAdd NamespaceEventType = "add" + NamespaceEventUpdate NamespaceEventType = "update" + NamespaceEventRemove NamespaceEventType = "remove" + NamespaceEventResync NamespaceEventType = "resync" +) + +type NamespaceEvent struct { + Type NamespaceEventType + Name string + Labels map[string]string + Source NamespaceSource + ObservedAt time.Time +} + +type NamespaceSubscriber interface { + Name() string + OnNamespaceAdd(ctx context.Context, ns NamespaceRecord) error + OnNamespaceRemove(ctx context.Context, ns NamespaceRecord) error + OnNamespaceResync(ctx context.Context, ns NamespaceRecord) error +} +``` + +--- + +## Как должен работать startup + +Это один из самых важных разделов. Сейчас именно startup/runtime symmetry остаётся центральным требованием. + +### Текущий анти-pattern + +Сначала что-то строится по env namespaces, потом dynamic path делает другой набор действий отдельно. + +### Целевой startup + +При старте процесса manager должен: + +1. Собрать namespaces из env. +2. Сделать backfill всех существующих namespaces с label `fission.io/managed=true`. +3. Нормализовать список без дублей. +4. Сформировать initial desired set. +5. Пропустить весь этот set через тот же reconcile pipeline, что и поздние события. +6. Только потом считать manager готовым. + +Иначе говоря: + +startup — это просто массовый initial reconcile, а не отдельная логика «в обход». + +--- + +## Как должен работать runtime add + +Когда watcher видит новый namespace или relabel в `managed=true`, он не должен сам лезть во все компоненты. + +Он должен только отправить event в manager: + +```go +RegisterDesired(NamespaceEvent{Type: Add, Name: ns, Source: Watcher, Labels: ...}) +``` + +Дальше manager: + +1. Обновляет/создаёт `NamespaceRecord`. +2. Ставит phase `registering`. +3. По подписчикам запускает reconcile `OnNamespaceAdd`. +4. Фиксирует state каждой части. +5. Если все обязательные части успешны, переводит namespace в `active`. +6. Если часть упала, переводит в `failed` с возможностью повторной reconcile. + +Это важно: add должен быть idempotent и retry-friendly. + +--- + +## Как должен работать runtime remove + +Это следующий большой пробел в текущем Layer 1. + +Нужен единый remove path для двух случаев: + +1. namespace удалён; +2. label `fission.io/managed=true` снят. + +Пайплайн должен быть таким: + +1. Watcher сообщает `remove` event. +2. Manager помечает namespace как `deregistering`. +3. Вызывает `OnNamespaceRemove` у подписчиков. +4. Каждый подписчик: + - останавливает локальные informers; + - удаляет namespace из локальных lister maps; + - очищает связанный cache state. +5. После успешного снятия подписок manager переводит namespace в `removed` или удаляет запись полностью. + +Главная причина делать это централизованно: + +если remove semantics будут разъезжаться по компонентам, получится новая версия текущей проблемы, только уже в lifecycle удаления. + +--- + +## Как должен работать reconcile + +Remove/add недостаточно. Нужен ещё reconcile. + +Причины: + +1. Компонент мог стартовать позже manager-а. +2. Подписчик мог упасть на середине регистрации namespace. +3. Restart процесса может привести к тому, что локальная память пуста, а кластерное состояние уже существует. + +Поэтому manager должен уметь периодически или по событию заново прогонять namespace через subscriber-ов. + +Например: + +```go +OnNamespaceResync(ctx, ns) +``` + +Или через тот же `OnNamespaceAdd`, если он строго idempotent. + +Инженерно я бы предпочёл следующее правило: + +1. `OnNamespaceAdd` и `OnNamespaceResync` могут быть одной реализацией. +2. Но семантически различать их всё равно полезно для логов и метрик. + +--- + +## Кто должен быть subscriber-ами + +### 1. Executor subscriber + +Внутри него можно уже вызывать внутренние add/remove/resync по типам: + +- poolmgr +- newdeploy +- container + +Но для manager это один subscriber уровня executor. + +Почему это лучше: + +1. Manager не должен знать детали каждого executor type. +2. Executor сам лучше знает, что для него является complete registration. + +### 2. Router subscriber + +Отвечает за: + +- func informer; +- trigger informer; +- resolver informer registry; +- invalidate/rebuild path. + +### 3. BuilderMgr subscriber + +Но внутри него стоит сделать внутреннее разделение частей: + +- env watcher part; +- pkg watcher part. + +Именно потому, что на этом месте уже был пойман баг локального dedup. + +### 4. Provisioning subscriber + +Отдельный subscriber для: + +- `fission-fetcher` SA; +- возможно builder SA; +- связанных Role/RoleBinding path. + +Почему это должен быть отдельный subscriber: + +сейчас provisioning встроен как side effect watcher-а, а это делает sequencing слишком хрупким и плохо наблюдаемым. + +--- + +## Почему provisioning нужно вынести отдельно + +Сейчас логика «namespace зарегистрирован» и логика «в namespace создан нужный service account + RBAC» слишком слеплены. + +Это вредно по нескольким причинам: + +1. Трудно диагностировать, что именно сломалось: discovery, informer wiring или RBAC provisioning. +2. Нельзя отдельно повторить provisioning без повторного полного namespace registration. +3. Нельзя нормально отслеживать частичный success. + +Целевой дизайн: + +- manager знает, что provisioning — это отдельная обязательная или полуобязательная часть namespace lifecycle; +- provisioning subscriber отдаёт свой статус отдельно; +- при необходимости его можно повторно reconcile без переинициализации router/executor/buildermgr. + +--- + +## Нужен ли новый объект вместо NamespaceResolver + +Да, но не обязательно удалять `NamespaceResolver` в один момент. + +Реалистичная стратегия: + +### Этап A + +Сделать `NamespaceResolver` внутренней реализацией snapshot/compat layer. + +### Этап B + +Поверх него построить `NamespaceManager` как orchestration layer. + +### Этап C + +Постепенно вычистить прямые зависимости компонентов от `NamespaceResolver` и перевести их на manager/subscriber contract. + +Почему так, а не сразу delete old resolver: + +1. Слишком много мест уже используют текущие helper-ы. +2. Нужен период совместного существования старого snapshot API и нового orchestration API. +3. Иначе blast radius снова станет слишком большим. + +--- + +## Минимальный состав внутренних методов manager-а + +Ниже не внешний API, а то, что почти наверняка понадобится внутри. + +```go +func (m *manager) upsertRecord(event NamespaceEvent) NamespaceRecord +func (m *manager) markPartState(ns string, subscriber string, state NamespacePartState) +func (m *manager) markPhase(ns string, phase NamespacePhase, err error) +func (m *manager) snapshotActiveNamespaces() []string +func (m *manager) emit(event internalEvent) +func (m *manager) reconcileNamespace(ctx context.Context, name string) +func (m *manager) removeNamespace(ctx context.Context, name string) +``` + +Причина: если manager не умеет хранить part-level state, он снова выродится в glorified map. + +--- + +## Какой порядок вызовов нужен при add + +Не просто «вызвать всех subscriber-ов подряд». Нужна осознанная последовательность. + +Один из возможных вариантов: + +1. Provisioning subscriber +2. BuilderMgr subscriber +3. Executor subscriber +4. Router subscriber + +Но это не единственный вариант. Важно другое: порядок должен быть явным и объяснимым. + +Почему provisioning логично раньше: + +если namespace ещё не имеет нужного service account, часть runtime path может не подняться корректно. + +Почему router можно позже: + +он меньше зависит от SA provisioning, чем runtime execution path. + +Но я бы не жёстко кодировал этот порядок как случайную последовательность callback-ов. Лучше иметь явно заданную subscriber order policy. + +--- + +## Как manager должен вести себя при частичном падении + +Это одна из самых важных деталей, потому что сейчас система часто мыслит бинарно: success/fail. + +Нужно поведение такого типа: + +1. Executor зарегистрировался успешно. +2. Router зарегистрировался успешно. +3. BuilderMgr не зарегистрировался. +4. Namespace получает phase `failed` или `active-with-errors`. +5. В record фиксируется, что именно сломалось. +6. Reconcile можно повторить только для buildermgr part. + +Именно это позволит избегать режимов «namespace вроде есть, но реально не полностью обслуживается, а система этого не видит». + +--- + +## Метрики и логирование + +Без этого новый manager будет трудно отлаживать. + +Нужно как минимум: + +### Метрики + +- число active namespaces; +- число failed namespaces; +- число reconcile attempts; +- число add/remove events; +- количество ошибок по subscriber-ам. + +### Логи + +На каждое важное событие должны быть логи такого класса: + +- namespace discovered; +- namespace registration started; +- subscriber registration succeeded; +- subscriber registration failed; +- namespace active; +- namespace deregistering; +- namespace removed; +- resync started/completed. + +Без этого следующая стадия дебага снова упрётся в разрозненные логи компонентов. + +--- + +## Тестовая стратегия для нового этапа + +Нельзя ограничиться только unit tests отдельных helper-ов. + +Нужны как минимум четыре слоя проверок. + +### 1. Unit tests manager state machine + +- add нового namespace; +- повторный add идемпотентен; +- remove переводит в нужную фазу; +- partial failure отражается в part states. + +### 2. Unit tests subscriber ordering / reconcile + +- add вызывает всех нужных subscriber-ов; +- failure одного subscriber-а не портит состояние других; +- повторный resync догоняет незарегистрированную часть. + +### 3. Component tests + +- buildermgr add/remove; +- router add/remove; +- newdeploy add parity; +- executor resync. + +### 4. End-to-end tests + +- startup with existing managed namespaces; +- late namespace add; +- relabel add; +- label removal; +- namespace delete; +- process restart; +- burst onboarding. + +--- + +## Как бы я разбил реализацию следующего этапа на коммиты + +Это очень важно: не повторять ошибку большого rewrite. + +### Commit A + +Добавить скелет `NamespaceManager` и in-memory record model без подключения компонентов. + +Цель: + +- новый тип существует; +- есть unit tests state model; +- legacy path ещё не тронут. + +### Commit B + +Подключить discovery path: env + namespace watcher events начинают идти в manager. + +Но subscribers пока можно ограничить одним compatibility subscriber. + +### Commit C + +Сделать provisioning отдельным subscriber-ом. + +### Commit D + +Перевести buildermgr на manager/subscriber contract. + +Почему именно buildermgr первым: + +там уже был пойман реальный dedup defect, и логика явно просит более чистый lifecycle. + +### Commit E + +Перевести router на manager/subscriber contract. + +### Commit F + +Перевести executor subscriber. + +### Commit G + +Добавить remove/relabel/delete lifecycle. + +### Commit H + +Вычистить legacy прямые обращения к resolver там, где это уже возможно. + +--- + +## Что можно оставить совместимым на переходный период + +Не всё нужно ломать сразу. + +Можно временно оставить: + +1. `Snapshot()` API у `NamespaceResolver` как compatibility layer. +2. Часть существующих helper-ов для informer factory creation. +3. Отдельные component-specific `AddNamespace()` методы, но вызывать их уже через manager subscriber. + +Это позволит переподключать компоненты последовательно. + +--- + +## Какие риски у самого NamespaceManager rewrite + +Нужно честно фиксировать и риски новой архитектуры. + +### 1. Over-centralization + +Если сделать manager слишком умным, он начнёт знать внутренности каждого компонента, и получится новый монолит уже поверх старого. + +Поэтому manager должен оркестрировать lifecycle, но не содержать доменную логику executor/router/buildermgr. + +### 2. Deadlocks или долгие lock sections + +Если state manager будет держать lock во время вызова subscriber-ов, это плохой дизайн. + +Нужно правило: + +- lock только на обновление внутреннего state; +- вызовы subscriber-ов делать вне глобального lock. + +### 3. Excessive retries + +Если reconcile не ограничить и не сделать наблюдаемым, можно получить noisy system с бесконечными повторными попытками. + +### 4. Confused ownership + +Если не определить, кто отвечает за remove/reconcile конкретной части, получится новая версия старой размазанной логики. + +--- + +## Что я считаю правильным следующим шагом после этого документа + +Не сразу кодить full manager. + +Сначала нужен ещё один маленький подготовительный шаг: + +1. Добавить новый package или файл со skeleton model `NamespaceRecord`, `NamespacePhase`, `NamespaceEvent`. +2. Покрыть его unit tests. +3. Не подключать пока к production lifecycle. + +Почему: + +это даст опорную модель данных, вокруг которой уже можно строить manager, не смешивая сразу storage, watchers и subscribers. + +--- + +## Итог + +Целевой `NamespaceManager` для Layer 1 — это не «один общий namespace» и не «ещё один helper над map`ой`». + +Это должен быть orchestration слой с пятью обязательными свойствами: + +1. единый lifecycle add/remove/resync; +2. state model с phase и part-level status; +3. подписчики-компоненты вместо хаотичных side effects; +4. symmetry startup и runtime onboarding; +5. безопасный reconcile после ошибок и restart. + +Только после этого можно сказать, что Layer 1 действительно перестал быть монопользовательским Fission с набором динамических заплаток и стал многопользовательским control-plane слоем с понятным жизненным циклом. \ No newline at end of file