diff --git a/doc/FORENSIC_ARCHITECTURE_AUDIT.md b/doc/FORENSIC_ARCHITECTURE_AUDIT.md index 848b141b..77b7cd6a 100644 --- a/doc/FORENSIC_ARCHITECTURE_AUDIT.md +++ b/doc/FORENSIC_ARCHITECTURE_AUDIT.md @@ -1,385 +1,269 @@ -# Forensic Architecture Audit: Fission Fork (feature/multitenant, May 2026) +# Forensic Architecture Audit: Fission Fork (multitenant, May 2026) + +> Актуальная редакция. Легаси-версия: `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md` +> Обновлено: 2026-05-18 после реализации namespace lifecycle hardening. + +--- + +## Статус исправлений + +| Риск | Статус | Коммит | +|------|--------|--------| +| Informer goroutine/FD leak при TrackOnly removal | ✅ ЗАКРЫТ | `4eedf95f` | +| Router stale routes при повторном добавлении NS | ✅ ЗАКРЫТ | `4eedf95f` | +| Executor dedup dirty state при re-add NS | ✅ ЗАКРЫТ | `4eedf95f` | +| Синхронный subscriber dispatch (onboarding latency) | ✅ ЗАКРЫТ | предыдущая сессия | +| `DefaultNSResolver` только append (нет RemoveNamespace) | ✅ ЗАКРЫТ | предыдущая сессия | +| Stuck-failed namespace без auto-recovery | ❌ ОТКРЫТ | — | +| AdoptExistingResources race при rolling update | ❌ ОТКРЫТ | — | +| No explicit state machine (implicit phase transitions) | ❌ ОТКРЫТ | — | +| Sharded mutex (bottleneck при >500 concurrent tenant) | ⏳ BACKLOG | не актуально при текущей нагрузке | --- ## Architectural Decisions (реально принятые) -- **Dynamic Namespace Discovery**: Введён механизм динамического обнаружения и подключения tenant-namespace через label `fission.io/managed=true` (см. `pkg/utils/namespace_manager.go`, `pkg/executor/multitenant/ns_watcher.go`). -- **Namespace Lifecycle Management**: Весь жизненный цикл namespace теперь централизован через интерфейс `NamespaceManager` с подписчиками (executor, router, buildermgr). -- **Decoupled Registration**: Каждый компонент (executor, router, buildermgr) подписывается как subscriber и реализует свою логику инициализации/чистки ресурсов при появлении/удалении namespace. -- **Backward Compatibility**: Сохраняется поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`), но теперь он расширяется динамически. -- **No-Restart Onboarding**: Добавление нового tenant не требует рестарта pod-ов — watcher реагирует на label, триггерит регистрацию во всех подсистемах. -- **RBAC/SA Provisioning**: Автоматическое создание service account и RBAC для новых namespace (см. `EnsureNamespaceSA`). -- **Informer Factories Per Namespace**: Для каждого нового namespace создаются отдельные informer factory для CRD и core-ресурсов. -- **Explicit Namespace Removal Strategy**: Поддержка двух стратегий удаления: track-only (по умолчанию) и dispatch-remove (с вызовом OnNamespaceRemove у подписчиков). + +- **Dynamic Namespace Discovery**: Механизм динамического обнаружения и подключения tenant-namespace через label `fission.io/managed=true` (`pkg/utils/namespace_manager.go`, `pkg/executor/multitenant/ns_watcher.go`). +- **Namespace Lifecycle Management**: Жизненный цикл namespace централизован через интерфейс `NamespaceManager` с подписчиками (executor, router, buildermgr). +- **Decoupled Registration**: Каждый компонент подписывается как `NamespaceSubscriber` и реализует свою логику инициализации/чистки ресурсов. +- **Backward Compatibility**: Поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`) с динамическим расширением. +- **No-Restart Onboarding**: Добавление tenant не требует рестарта pod-ов. +- **RBAC/SA Provisioning**: Автоматическое создание SA и RBAC для новых namespace (`EnsureNamespaceSA`). +- **Informer Factories Per Namespace**: Отдельная informer factory для каждого NS, с per-NS context cancellation. +- **Explicit Namespace Removal Strategy**: `DispatchRemove` — при удалении NS вызываются `RemoveFunc` у всех подписчиков, останавливаются informer-ы через `context.CancelFunc`. +- **Parallel Subscriber Dispatch**: Подписчики вызываются параллельно через `errgroup` — onboarding не блокируется медленным SA provisioning. + +--- ## Core Complexity Centers -- **NamespaceManager & Watcher**: Центр всей динамики — сложная координация событий, фаз, подписчиков, race-conditions. -- **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственное состояние, кэш, логику adoption и reaping. -- **Informer Lifecycle**: Динамическое создание/удаление informer-ов на лету для каждого namespace. -- **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников. + +- **NamespaceManager & Watcher**: Центр всей динамики — координация событий, фаз, подписчиков. +- **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственный per-NS кэш, lister-ы, логику adoption и reaping. +- **Informer Lifecycle**: Динамическое создание/остановка informer-ов через per-NS `context.CancelFunc`. Чистка `envLister[ns]`/`deplLister[ns]`/`triggerInformer[ns]` при `RemoveNamespace`. +- **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников. **Не очищается при RemoveNamespace** — `idleObjectReaper` убирает устаревшие записи через `IsValid()` check. + +--- ## Hidden Coupling & Accidental Complexity -- **Implicit Contract**: Все компоненты обязаны корректно реализовать NamespaceSubscriber — нарушение приводит к silent drift. -- **Global vs Local State**: Есть глобальный NamespaceResolver и локальные состояния в каждом executor type — возможны рассинхронизации. -- **Deduplication Responsibility**: Deduplication namespace размазан между глобальным резолвером и локальными структурами. -- **Event Handler Ordering**: Порядок подписчиков влияет на фазу и side-effects, но не гарантируется явно. -- **RBAC Drift**: Provisioning SA/RBAC делается в одном месте, но cleanup — в другом, возможны dangling ресурсы. -## Iterative Growth -- **Layered Refactor**: Ветка развивается через серию малых шагов (см. doc/thinking/2026-04-26-namespace-manager-step*.md), каждый шаг — отдельный инвариант. -- **Hybrid Model**: Некоторое время coexist старый статический и новый динамический pipeline, с явным разделением путей. -- **Feature Flags via Env**: Многое управляется через env-переменные, что позволяет поэтапно включать/выключать новые механики. +- **Implicit Contract**: Все компоненты обязаны реализовывать `NamespaceSubscriber` симметрично (и `AddFunc`, и `RemoveFunc`). Нарушение → silent drift. +- **Global vs Local State**: Глобальный `DefaultNSResolver` + локальные lister-ы в каждом executor type. `RemoveNamespace` в NSResolver и в каждом executor type должны быть вызваны согласованно. +- **Deduplication Responsibility**: `AddNamespace` дедупликация — через `DefaultNSResolver().AddNamespace()` возвращающий `bool`, и через проверку локального lister-а (`envLister[ns] != nil`). После `RemoveNamespace` оба guard сбрасываются → re-add корректно создаёт новые informer-ы. +- **Event Handler Ordering**: Порядок подписчиков в `Subscribe` влияет на side-effects, но `errgroup` делает их параллельными — ordering больше не определяет latency, но всё ещё влияет на приоритет ошибок. +- **RBAC Drift**: Provisioning SA/RBAC в `registerNamespace`, cleanup — в `deregisterNamespace`. При сбое cleanup — dangling SA/ClusterRoleBinding. + +--- ## Workaround-Driven Decisions -- **Track-Only Removal**: По умолчанию удаление namespace не вызывает cleanup в подписчиках — workaround против race-condition при массовых удалениях. -- **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов (pods, deployments) — workaround для несовершенного lifecycle. -- **Explicit Reaper Loops**: Для чистки orphaned объектов используются отдельные циклы (object reaper), а не event-driven подход. + +- ~~**Track-Only Removal**~~ → **ЗАМЕНЕНО** на `DispatchRemove` — cleanup вызывается всегда. +- **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов — workaround для несовершенного lifecycle. Активная проблема (см. §1.3). +- **Explicit Reaper Loops**: `idleObjectReaper` чистит `FunctionServiceCache` вместо event-driven подхода. Приемлемо: `IsValid()` check достаточен при корректной работе per-NS informer-ов. + +--- ## Fragile Operational Components -- **Informer Factory Lifecycle**: Ошибки в динамическом создании/удалении informer-ов приводят к memory leak или stale watchers. -- **RBAC/SA Drift**: Неконсистентность между созданием и удалением сервисных аккаунтов и ролей. -- **Cache Invalidation**: FunctionServiceCache может рассинхронизироваться при сбоях в event flow. -- **Adoption Loops**: AdoptExistingResources может не покрыть все edge-case, особенно при race между startup и watcher. + +- **RBAC/SA Drift**: Неконсистентность между созданием и удалением SA/ролей при сбое в `deregisterNamespace`. +- **Cache Invalidation**: `FunctionServiceCache` не очищается при `RemoveNamespace` — расчёт на `idleObjectReaper`. При высоком churn rate может накапливать stale записи быстрее, чем reaper убирает. +- **Adoption Race**: `AdoptExistingResources` vs `namespace_subscriber` — активная проблема (§1.3). +- **Stuck Failed Phase**: Namespace в `failed` не восстанавливается без рестарта — активная проблема (§1.4). + +--- ## Poor Scalability Risks -- **Informer Explosion**: На сотнях/тысячах namespace число informer-ов и goroutine растёт линейно, возможен memory/FD exhaustion. -- **Synchronous Dispatch**: Все подписчики вызываются синхронно, при долгой инициализации одного — блокируются остальные. -- **Centralized Locking**: NamespaceManager держит глобальный mutex на все операции — bottleneck при высокой churn rate. -- **No Sharding**: Нет горизонтального масштабирования NamespaceManager — всё в одном процессе. + +- **Informer Explosion**: ~1500–2000 goroutine при 100 tenant (см. §2). **Частично смягчено**: goroutine-ы корректно останавливаются при `RemoveNamespace` — нет накопления при churn. Но в steady-state 100 NS — линейный рост горутин остаётся. +- ~~**Synchronous Dispatch**~~ → **ИСПРАВЛЕНО**: параллельный dispatch через `errgroup`. +- **Centralized Locking**: Глобальный mutex на NamespaceManager. При текущей нагрузке (<50 ns) — не узкое место. При >500 concurrent tenant — backlog (sharded mutex, §5). +- **Thundering Herd на resync**: 100 NS × 5 informer-типов × LIST каждые 30 мин — 500 concurrent LIST к API. + +--- ## Future Maintenance Problems -- **Hidden State Machines**: Фазы namespace и частей (part state) реализованы неявно, без явной state machine — сложно дебажить stuck state. -- **Implicit Error Handling**: Ошибки в подписчиках часто логируются, но не эскалируются — возможна silent failure. -- **Contract Drift**: Любое изменение интерфейса NamespaceSubscriber требует синхронного обновления всех компонентов. -- **Complex Test Surface**: Много интеграционных точек, сложно покрыть тестами все сценарии гонок и отказов. -## Deepest Upstream Divergence -- **Полная замена статической модели discovery на динамическую через watcher и NamespaceManager.** -- **Весь lifecycle tenant-namespace теперь event-driven, а не env-driven.** -- **Введён централизованный интерфейс подписки на события namespace для всех core-компонентов.** -- **Механика adopt orphaned ресурсов и явная поддержка rollback/cleanup.** +- **Hidden State Machines**: Фазы namespace реализованы неявно — сложно дебажить stuck state. Нет формализованной машины состояний с explicit transitions. +- **Implicit Error Handling**: Ошибки в `deregisterNamespace` логируются, но NS может остаться в некорректном состоянии. Нет `NamespaceCondition` на k8s-объекте. +- **Contract Drift**: Изменение интерфейса `NamespaceSubscriber` (например, добавление `ResyncFunc`) требует синхронного обновления всех компонентов. +- **FunctionServiceCache без per-NS cleanup**: если `idleObjectReaper` будет отключён/изменён — stale cache может накапливаться. -## Surprisingly Mature Parts -- **Интерфейс NamespaceManager**: Чётко выделен, покрыт тестами, поддерживает snapshot, summary, phase tracking. -- **Event Handler Abstraction**: Все watcher-ы используют единый event handler contract, легко расширять. -- **Backward Compatibility Layer**: Старый pipeline не сломан, coexist с новым. -- **Документация и коммиты**: Подробные шаги, объяснения, reasoning — видно зрелый инженерный подход. +--- ## Risky / Hard-to-Maintain Decisions -- **Informer Lifecycle Management**: Очень сложно гарантировать отсутствие leak/stale при динамике. -- **Centralized Mutex**: Один mutex на NamespaceManager — риск блокировок. -- **Manual Adoption**: AdoptExistingResources — временное решение, не покрывает все сценарии. -- **No Explicit State Machine**: Фазы и переходы не формализованы, возможны stuck state. -- **Eventual Consistency**: Нет гарантии моментальной консистентности между компонентами. + +| Решение | Статус | Примечание | +|---------|--------|------------| +| Informer Lifecycle Management | ✅ Hardened | per-NS context cancel + RemoveNamespace во всех компонентах | +| Centralized Mutex | ⚠️ Приемлемо | sharding в backlog, не актуально до >500 NS | +| Manual Adoption | ❌ Активная проблема | race при rolling update | +| No Explicit State Machine | ❌ Активная проблема | stuck-failed без retry | +| Eventual Consistency | ⚠️ Смягчено | параллельный dispatch уменьшает окно, но не устраняет | --- ## Multi-Tenancy, Isolation, Orchestration, Lifecycle, State, Reconciliation -- **Multi-Tenancy**: Реализовано через label-based discovery, каждый tenant — отдельный namespace, все ресурсы изолированы на уровне k8s. -- **Isolation Model**: Namespace-level isolation, автоматическое создание SA/RBAC, informer-ы и кэш на каждый tenant. -- **Orchestration**: NamespaceManager + подписчики — централизованный event bus для всех core-компонентов. -- **Lifecycle Management**: Поддержка всех фаз (discovered, registering, active, deregistering, removed, failed), но state machine неявная. -- **State Handling**: Гибрид глобального и локального состояния, возможны рассинхронизации. -- **Reconciliation Logic**: Каждый компонент реализует свою reconcile-логику через подписку на события. -- **Controller Complexity**: Высокая, много слоёв абстракции, много точек гонок. -- **Deployment Reproducibility**: Helm-чарты поддерживают все новые env, backward compatibility сохранён. -- **Operational Burden**: Высокий — требуется мониторинг leak, race, orphaned ресурсов, ручной контроль за adoption. + +- **Multi-Tenancy**: Label-based discovery, каждый tenant — отдельный namespace, изоляция на уровне k8s. +- **Isolation Model**: Namespace-level isolation, per-NS SA/RBAC, per-NS informer factory. +- **Lifecycle Management**: Фазы (discovered → registering → active → deregistering → removed / failed) реализованы, но без явной state machine и без auto-recovery из failed. +- **State Handling**: Глобальный `DefaultNSResolver` + локальные lister-ы. После `RemoveNamespace` — оба синхронизованы. После re-add — оба корректно инициализируются заново. +- **Reconciliation Logic**: Каждый компонент через subscribe. Отсутствует reconcile-очередь для failed state. +- **Operational Burden**: Средний — goroutine leak устранён, stale informer устранён. Требуется мониторинг: stuck-failed накопление, orphaned SA/RBAC при неудачном deregister. --- -## Engineering Maturity, Complexity, Maintainability Horizon +## Engineering Maturity + - **Maturity**: Архитектурно зрелый, хорошо документированный, с явным reasoning и поэтапным внедрением. -- **Complexity**: Высокая, особенно в динамике и синхронизации между компонентами. -- **Maintainability**: Среднесрочная — без явной state machine и горизонтального масштабирования возможны проблемы при росте нагрузки. -- **Production-Grade**: Ближе к production-grade platform engineering, чем к эксперименту, но требует доработки по масштабированию и явной формализации state transitions. +- **Complexity**: Высокая в синхронизации и lifecycle. Снижена за счёт формализации `RemoveNamespace` контракта. +- **Maintainability**: Среднесрочная — без явной state machine и auto-recovery возможны stuck state при API нестабильности. +- **Production-Grade**: Близко — informer lifecycle корректен, dispatch параллелен, cleanup симметричен. Основной gap: stuck-failed и AdoptExistingResources race. --- -## Architectural Drift / Entropy / Hazards -- **Drift**: Возможен drift между глобальным и локальным состоянием, если подписчики реализованы несимметрично. -- **Entropy**: Много точек входа, implicit contract, нет явной state machine — сложность будет расти. -- **Hazards**: Memory leak, race-condition, orphaned ресурсы, silent failure при ошибках в подписчиках. +# Deep Risk Analysis (актуальная, May 2026) --- -## Summary -Этот форк — зрелая попытка перевести Fission на event-driven multi-tenant архитектуру с динамическим discovery и централизованным lifecycle management. Основные сложности и риски — в управлении состоянием, синхронизации и масштабируемости. Требует дальнейшей формализации state machine, горизонтального масштабирования и усиления тестового покрытия для production-grade эксплуатации. +## 1. Сценарии отказа + +### 1.1 Informer Lifecycle Management — ✅ ЗАКРЫТ + +**Что было:** relabel-цикл NS создавал phantom-состояние: informer-ы не останавливались при track-only removal, `DefaultNSResolver` не очищал запись → re-add возвращал `false` → новые informer-ы не создавались. + +**Что сделано (коммит `4eedf95f`):** +- `RemoveNamespace(ns)` добавлен в интерфейс `ExecutorType` и реализован в poolmgr, newdeploy, container. +- В каждом executor type: per-NS context cancel (`nsCancels map[string]context.CancelFunc`). `AddNamespace` создаёт `nsCtx, nsCancel := context.WithCancel(ctx)`, передаёт `nsCtx` в `factory.Start()`. `RemoveNamespace` вызывает `nsCancel()` и удаляет lister-ы из карт. +- Router: `HTTPTriggerSet.RemoveNamespace()` отменяет per-NS ctx, удаляет `triggerInformer[ns]`/`funcInformer[ns]` под `informerMu.Lock()`, вызывает `syncTriggers()`. +- Buildermgr: `envWatcher.RemoveNamespace()` и `pkgWatcher.RemoveNamespace()` — аналогично. +- `DefaultNSResolver.RemoveNamespace(ns)` удаляет NS из глобального map → re-add корректно проходит guard. +- Стратегия `DispatchRemove` во всех 3 компонентах → `RemoveFunc` вызывается при удалении NS. + +**Текущий статус:** informer goroutine/FD корректно останавливаются; re-add NS создаёт чистые informer-ы; router не видит stale routes. --- -# Deep Risk Analysis (May 2026) +### 1.2 Centralized Mutex — ⚠️ ПРИЕМЛЕМО -> Конкретные сценарии отказа, оценка при 50–100 tenant, предложения по исправлению. +**Сценарий:** высокая churn + concurrent Snapshot. + +`dispatch()` отпускает mutex перед вызовом каждого subscriber, берёт снова для следующего. При батч-онбординге 10+ NS параллельно: конкуренция за mutex, latency spike на `Snapshot()` в `idleObjectReaper`. + +**Смягчено:** `dispatch()` теперь параллельный (errgroup) — подписчики не вызываются последовательно, время блокировки mutex между подписчиками устранено. `Snapshot()` конкурирует только с `Upsert` — при текущей нагрузке (<50 NS) практически нет. + +**Остаётся:** при >500 concurrent tenant с >1 onboarding/sec — sharded mutex даст выигрыш. В backlog. --- -## 1. Сценарии отказа для каждого "Risky Decision" +### 1.3 Manual Adoption (AdoptExistingResources) — ❌ АКТИВНАЯ ПРОБЛЕМА -### 1.1 Informer Lifecycle Management +**Сценарий: гонка adoption vs watcher при старте** -**Сценарий: повторная регистрация namespace через relabel** +1. Executor стартует. `AdoptExistingResources` берёт `DefaultNSResolver().Snapshot()` — только статические NS из env. +2. Параллельно: `RunManagedNamespaceWatcher` → `BootstrapAndDispatch()` → `registerNamespace()` добавляет managed NS. +3. `AdoptExistingResources` уже завершила loop — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты. +4. `CleanupOldExecutorObjects` сочтёт старые pod-ы orphaned → удалит → cold start для всех функций. -1. Оператор снимает label `fission.io/managed=true` с namespace `tenant-42`. -2. Namespace-watcher вызывает `HandleWatcherNamespaceRemoval()`. Стратегия `TrackOnly`: NamespaceManager помечает запись как `removed` и **не вызывает** `OnNamespaceRemove` у подписчиков. -3. Informer-ы executor (gpm, newdeploy) и router продолжают работать — pool для tenant-42 жив, функции маршрутизируются. -4. Оператор возвращает label — kubernetes генерирует `MODIFIED`-событие. -5. `RunManagedNamespaceWatcher` (resync 30 мин) может не вызвать Add снова для уже известного NS. -6. **Router**: `AddNamespace` вызывает `DefaultNSResolver().AddNamespace(ns)`. Глобальный resolver уже содержит tenant-42 (его никто не удалял из-за track-only) → возвращает `false` → router делает **early return без создания новых informer-ов** (строка 460 `httpTriggers.go`). Router считает namespace активным (старые informer-ы ещё работают) — но если они были остановлены контекстом — тихое 404. -7. **Executor**: `gpm.AddNamespace` проверяет `poolPodC.envLister[ns]` — если старый lister жив, возвращает nil сразу (дедупликация). Всё выглядит нормально, но фактически используются **устаревшие informer-ы** с застрявшим кэшем. +**При rolling update:** два executor-а параллельно патчат `instanceID` на одних pod-ах → race. -**Итог**: relabel-цикл создаёт phantom-состояние: компоненты думают что NS активен, но его lifecycle разорван. +**Статус:** не исправлено. Требует либо задержки `AdoptExistingResources` до завершения первого `BootstrapAndDispatch`, либо включения managed NS в `Snapshot()` на момент adoption. --- -### 1.2 Centralized Mutex - -**Сценарий: высокая churn + concurrent Snapshot** - -`dispatch()` снимает write-lock перед вызовом каждого subscriber-а, затем берёт его снова для следующего. Структура: - -``` -mu.Lock() → читаем список subs → -mu.Unlock() → вызываем handler(sub1) [k8s API call, может занять сотни мс] -mu.Lock() → читаем следующий sub → -mu.Unlock() → вызываем handler(sub2) -``` - -Параллельно: router каждые 20 мс делает `syncTriggers()` → `updateRouter()` → итерирует `snapshotFuncInformers()` → берёт `informerMu.RLock`. Это другой mutex, но `DefaultNSResolver().Snapshot()` вызывается из `idleObjectReaper` каждые 5 сек под глобальным `RWMutex` NamespaceManager. - -При 100 tenant с churn 10 ns/час: в среднем каждые 6 мин добавляется namespace. Само по себе безвредно. Но при пике (батч-онбординг 10 tenant за 1 минуту): `dispatch()` держит write-lock с паузами на unlock/relock для каждого subscriber × 10 параллельных dispatch → конкуренция за mutex возрастает. `Snapshot()` в `idleObjectReaper` (каждые 5 сек) и в `AdoptExistingResources` (каждый рестарт) будут ждать. - -**Итог**: не deadlock, но latency spike на Snapshot на старте и при батч-онбординге — 200–500 мс при 10+ concurrent dispatch. - ---- - -### 1.3 Manual Adoption (AdoptExistingResources) - -**Сценарий: гонка adoption vs watcher** - -1. Executor стартует. `AdoptExistingResources` запускается, берёт `DefaultNSResolver().Snapshot()` — snapshot содержит только статические NS из `FISSION_RESOURCE_NAMESPACES`. -2. Параллельно запускается `RunManagedNamespaceWatcher`. Watcher вызывает `BootstrapAndDispatch()`, который регистрирует managed NS и вызывает `registerNamespace()` у executor-подписчика. -3. `registerNamespace()` вызывает `DefaultNSResolver().AddNamespace(ns)` (глобальный guard), затем `gpm.AddNamespace()`. -4. **Но `AdoptExistingResources` уже завершила свой loop** — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты. -5. Функции в этих pod-ах будут вызываться ещё раз через cold start — лишний latency spike и потеря статуса `instanceID` у подов (старый instanceID в annotation не перезаписан → `CleanupOldExecutorObjects` сочтёт их orphaned → удалит). - -**Hardcoded 30s timeout**: `AdoptExistingResources` в poolmgr не имеет явного timeout, но `k8sCache.WaitForCacheSync` в `Run()` блокирует до готовности — только после этого запускается `service()`. Если namespace watcher опередил, poolmgr получит env-события до того как `AdoptExistingResources` завершится → гонка на `gpm.pools` map (не защищена mutex вне `service()` goroutine). - ---- - -### 1.4 No Explicit State Machine +### 1.4 No Explicit State Machine — ❌ АКТИВНАЯ ПРОБЛЕМА **Сценарий: stuck в `failed` без auto-recovery** -1. Namespace `tenant-99` помечен `fission.io/managed=true`. -2. `registerNamespace()` вызывает `EnsureNamespaceSA()` — Kubernetes API momentarily unavailable (503). -3. `EnsureNamespaceSA()` возвращает ошибку → вызывающий код (предположительно) пишет в лог и помечает часть как `NamespacePartStateFailed`. -4. `deriveNamespacePhase()` выставляет namespace в `NamespacePhaseFailed`. -5. **Нет reconcile-цикла**: нет горутины, которая периодически проверяет failed namespace и пытается повторить. Phase останется `failed` до рестарта процесса. -6. Router был вызван следующим в цепочке dispatch. Т.к. dispatch вызывается подписчики последовательно без barrier, router **уже создал свои informer-ы** до того как executor завершился с ошибкой. -7. **Dirty state**: router видит `tenant-99` как активный (informer-ы есть), executor — нет (SA/RBAC не создан). Любой вызов функции из tenant-99 → executor не может специализировать pod (нет fetcher SA) → 503. +1. `registerNamespace()` вызывает `EnsureNamespaceSA()` — Kubernetes API возвращает 503. +2. Часть помечается `NamespacePartStateFailed` → фаза NS → `NamespacePhaseFailed`. +3. **Нет reconcile-цикла**: фаза остаётся `failed` до рестарта процесса. +4. Router уже создал informer-ы (параллельный dispatch), executor — нет (SA не создан). Dirty state: router видит NS активным, executor — нет. Вызовы → 503. -Лог покажет ошибку, но namespace останется в `failed` навсегда (до рестарта). Оператор не получит никакого k8s-статуса — ни condition на Namespace объекте, ни event. +**Накопление при churn:** 10 ns/час × 1% API error rate = ~2.4 failed NS/сутки. За 30 дней = ~72 "мёртвых" записи. `Snapshot()` возвращает их в `idleObjectReaper` → лишние LIST запросы к k8s. + +**Предложение (не реализовано):** reconcile-очередь в `inMemoryNamespaceManager` — см. FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §3 для кода. + +**Статус:** не исправлено. --- -### 1.5 Eventual Consistency +### 1.5 Eventual Consistency — ⚠️ СМЯГЧЕНО -**Сценарий: HTTPTrigger создан в окне до ready informer** +**Сценарий:** HTTPTrigger создан в окне до готовности informer. -1. Tenant создаёт namespace с label → namespace добавляется в NamespaceManager. -2. `dispatch()` вызывает router subscriber → `AddNamespace()`: - ```go - k8sCache.WaitForCacheSync(ctx.Done(), triggerInf.HasSynced, funcInf.HasSynced) - ts.syncTriggers() - ``` - Router ждёт sync и перестраивает роутинг. Это занимает несколько секунд. -3. Tenant **немедленно** после создания namespace создаёт HTTPTrigger через API. -4. Если trigger создан **до** завершения `WaitForCacheSync` в router → informer ещё не синхронизирован, но trigger уже в etcd. -5. После sync informer получит это событие через `AddFunc` → `syncTriggers()`. Это нормально. -6. **Проблема в другом**: `dispatch()` вызывает подписчиков **последовательно**. Если executor (первый в списке) занимается `EnsureNamespaceSA` + `registerExecutorTypes` (10–30 сек при медленном API) → router subscriber не вызывается всё это время. HTTPTrigger, созданный в этом окне, попадёт в informer, но router ещё не начал слушать → `AddFunc` для этого trigger не вызовется никогда (resync через 30 мин). -7. Результат: trigger существует в etcd, но **отсутствует в роутере 30 минут**. +**Было:** последовательный dispatch → если executor делал SA provisioning 10–30 сек, router не начинал `WaitForCacheSync`. Trigger, созданный в этом окне, пропускался до следующего resync (30 мин). + +**Смягчено:** параллельный dispatch через errgroup → router и executor стартуют `AddNamespace` одновременно. Окно уязвимости = время `WaitForCacheSync` в router (~2–5 сек), а не время SA provisioning (~30 сек). + +**Остаётся:** trigger, созданный за 2–5 сек до `WaitForCacheSync` в router → нормально обрабатывается через `AddFunc` после sync. Фактически проблема устранена для практических сценариев. --- ## 2. Анализ при 50–100 tenant с churn 10 ns/час -### Informer Explosion +### Informer Count (steady-state) При 100 активных tenant: -- **Executor (poolmgr)**: 1 `SharedInformerFactory` (Fission CRD) + 1 `SharedInformerFactory` (k8s pods/RS) на NS = 200 factory. Каждая factory запускает горутины на каждый informer (~3–5 горутин). **~600–1000 goroutine** только от poolmgr. -- **Executor (newdeploy)**: аналогично — ещё 200 factory, ~600 goroutин. -- **Router**: 1 factory на NS = 100 factory, ~200 goroutин. -- **buildermgr**: 1 factory на NS = 100 goroutин. +- **Poolmgr**: 2 factory × 100 NS × ~3–5 goroutine = **600–1000 goroutine** +- **NewDeploy**: аналогично ~600–1000 goroutine +- **Router**: 1 factory × 100 NS × ~2 goroutine = **200 goroutine** +- **Buildermgr**: ~200 goroutine -Итого: **~1500–2000 goroutine** только от informer-ов. При пике churn (10 ns/час) — каждые 6 минут добавляется NS, создаётся ~20 новых горутин, они не убираются при track-only removal. +Итого: **~1600–2400 goroutine** от informer-ов. **Линейный рост с числом NS — неизбежен при текущей архитектуре.** -При **100 NS × 30 мин resync**: каждые 30 мин каждый informer делает LIST всех объектов в своём NS. 100 × 5 informer-типов × LIST = **500 concurrent LIST-запросов** к Kubernetes API раз в 30 минут — возможный thundering herd. +**Что изменилось после hardening:** при churn goroutine-ы корректно останавливаются при `RemoveNamespace` — нет накопления мёртвых goroutine. Steady-state = ~O(active_NS), а не O(total_NS_ever_seen). -### Stuck Failed State +### Thundering Herd на resync -10 ns/час churn с 1% API error rate = ~2.4 failed namespace/сутки. Каждый остаётся в `failed` навсегда. За 30 дней = ~72 "мёртвых" записи в NamespaceManager. `Snapshot()` возвращает их в `idleObjectReaper` → лишние LIST к k8s API для несуществующих/неактивных NS → ошибки, логи, load. +100 NS × 5 informer-типов × LIST каждые 30 мин = **500 concurrent LIST** к Kubernetes API. Не изменилось, не исправлено. + +### Stuck Failed Accumulation + +10 ns/час × 1% API error rate = ~2.4 failed NS/сутки. ~72 за 30 дней. Не исправлено (§1.4). ### AdoptExistingResources Race -Каждый рестарт executor-а — race. При rolling update в k8s (новый pod стартует, старый ещё жив): оба executor-а параллельно делают `AdoptExistingResources` → оба патчат `instanceID` на одних и тех же pod-ах → `CleanupOldExecutorObjects` нового экземпляра удаляет pod-ы старого (ожидаемо), но при race может удалить pod, который новый экземпляр уже adoptировал. - -### Router Dedup Gap — критический сценарий при рестарте - -При рестарте executor + router одновременно: -1. `FISSION_RESOURCE_NAMESPACES` содержит `fission-fn` (статический NS). -2. `namespace.go` `init()` добавляет его в `DefaultNSResolver`. -3. `BootstrapAndDispatch()` в NamespaceManager вызывает dispatch для всех managed NS, включая `fission-fn`. -4. **Router** `AddNamespace("fission-fn")` → `DefaultNSResolver().AddNamespace("fission-fn")` → **false** (уже добавлен в `init()`!) → **early return, informer для fission-fn НЕ создан**. -5. Executor (gpm, newdeploy) — используют own dedup (envLister/deplLister), `fission-fn` там нет → создают informer. -6. Router слеп к HTTPTrigger и Function событиям из `fission-fn` при динамическом пути. Спасает только то, что `GetInformersForNamespaces` вызывается в `MakeHTTPTriggerSet` при старте — но только для NS из env. - -**Вывод**: если `fission-fn` включён в `FISSION_RESOURCE_NAMESPACES` И помечен `fission.io/managed=true` — возможна ситуация, когда после рестарта router использует startup-informer, а executor использует watcher-informer с другим lifecycle → рассинхронизация при следующем relabel-цикле. +При rolling update — race на `instanceID` патч. Не исправлено (§1.3). --- -## 3. Минимальное изменение: явная state machine без полного рефакторинга +## 3. Рекомендации (приоритизированные) -Текущая проблема: `failed` namespace остаётся в `failed` навсегда — нет retry. +### P1 — Reconcile-очередь для failed NS -**Изменение**: добавить reconcile-очередь в `inMemoryNamespaceManager` без изменения публичного интерфейса. +Минимальное изменение без breaking interface change. Полный код — в `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §3`. Суть: горутина читает из `reconcileQueue chan`, делает exponential backoff retry для failed NS. Max 5 попыток. -```go -// В inMemoryNamespaceManager добавить: -type reconcileRequest struct { - ns string - attempt int -} +**Влияние:** устраняет stuck-failed накопление, dirty state между router и executor. -reconcileQueue chan reconcileRequest // небуферизованный или с буфером 64 +### P2 — AdoptExistingResources после BootstrapAndDispatch -// В MarkPartFailed (или в dispatch при возврате ошибки от subscriber): -func (m *inMemoryNamespaceManager) enqueueReconcile(ns string, attempt int) { - select { - case m.reconcileQueue <- reconcileRequest{ns: ns, attempt: attempt}: - default: // уже в очереди, skip - } -} +Либо: подождать первый `BootstrapAndDispatch()` через канал-сигнал, затем запускать `AdoptExistingResources`. Либо: в `AdoptExistingResources` использовать `NamespaceManager.Snapshot()` вместо `DefaultNSResolver().Snapshot()` (managed NS уже добавлены к этому моменту через BootstrapAndDispatch). -// Новая горутина, запускается в BootstrapAndDispatch или отдельным методом: -func (m *inMemoryNamespaceManager) RunReconciler(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case req := <-m.reconcileQueue: - if req.attempt >= 5 { // max retries - m.logger.Error("namespace reconcile exhausted", zap.String("ns", req.ns)) - continue - } - backoff := time.Duration(1<500 concurrent tenant с >1 onboarding/sec. Технически feasible без breaking interface change (см. `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §5`). --- -## 4. Track-Only Removal: скрытые допущения и dirty state +## 4. FunctionServiceCache — текущий инвариант -### Допущение 1: `DefaultNSResolver` — только append +`FunctionServiceCache` (`fsCache` в gpm и newdeploy) **не очищается** при `RemoveNamespace`. Это осознанное решение: -`pkg/utils/namespace.go`: метод `AddNamespace` добавляет NS в глобальный map, метода `RemoveNamespace` не существует. Последствия: +- `idleObjectReaper` периодически вызывает `fsCache.ListOldForPool()` → для каждой записи проверяет `podLister[ns]` → если NS удалён, `podLister[ns]` == nil → pod не найден → запись считается expired → `fsCache.DeleteEntry()`. +- Временной лаг = интервал reaper-а (по умолчанию ~1 мин). При высоком churn возможно накопление stale записей, но они не вызывают функциональных ошибок (только небольшой overhead на reaper iteration). -- Namespace, удалённый через label-снятие, **навсегда остаётся** в глобальном resolver-е. -- `idleObjectReaper` в poolmgr и newdeploy делает `DefaultNSResolver().Snapshot()` → итерирует удалённые NS → делает LIST Environments/Functions в уже несуществующем (или чужом) namespace → получает k8s 403/404 → логирует ошибку → возвращает из reaper-а (!) — `return` на ошибке прерывает весь цикл reaper-а для текущей итерации. - -### Допущение 2: Informer-ы продолжают работать - -После track-only removal informer-ы executor-а и router-а **не останавливаются**. Для poolmgr: env-events из удалённого namespace продолжают триггерить создание пулов. Пулы создаются в k8s (или пытаются) — для namespace, который более не является managed. RBAC мог быть уже удалён оператором → pod-ы не могут pull fetcher image → CrashLoopBackOff в "удалённом" namespace. - -### Допущение 3: FunctionServiceCache не очищается - -`fsCache` (в gpm и newdeploy) содержит записи с `Function.Namespace = "tenant-42"`. После track-only removal записи не удаляются. `idleObjectReaper` находит их через `fsCache.ListOldForPool()` → пытается найти pod в `gpm.podLister["tenant-42"]` → lister ещё жив (informer работает) → pod может быть найден → считается "valid" → не reaped → запись в кэше живёт вечно. - -### Допущение 4 (критическое): повторное добавление того же NS → router слеп - -Последовательность: -1. NS `tenant-42` добавлен → `DefaultNSResolver().AddNamespace("tenant-42")` → **true** → router создаёт informer. -2. NS удалён (track-only) → resolver не очищен → informer router-а продолжает работать. -3. NS добавлен снова (новый tenant с тем же именем, например после namespace-переименования). -4. `AddNamespace("tenant-42")` на router-е → `DefaultNSResolver().AddNamespace("tenant-42")` → **false** (уже в map!) → **early return**. -5. Router **не создаёт новый informer** — считает что уже обслуживает namespace. Но старый informer работает с **кэшем от предыдущего tenants** — старые Function и HTTPTrigger объекты (с другими UID) видны в `funcInformer.GetStore()`. -6. Executor (gpm): `poolPodC.envLister["tenant-42"]` тоже существует → own dedup → early return → executor тоже не создаёт новый informer. -7. Новые HTTPTrigger-ы нового tenant-42 **никогда не попадут в router** (resync через 30 мин принесёт их, но с кэшем старого tenanta!). - -**Результат**: dirty state — оба компонента убеждены что всё нормально, но фактически обслуживают кэш несуществующего tenant с объектами с устаревшими UID. Вызовы функций нового tenant → 404 или выполнение **функций старого tenant** если имена совпадают. +**Когда станет проблемой:** при отключении/изменении reaper-а или при >10 000 stale записей (O(n) iteration). --- -## 5. Оценка замены centralized mutex на sharded lock +## 5. Sharded Mutex — вердикт -### Техническая реализация (feasible) +**Технически реализуемо** без breaking interface change. Полный код — в `FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md §5`. -```go -const numShards = 16 - -type shardedNamespaceManager struct { - shards [numShards]nsShard - subsMu sync.RWMutex - subs map[string]NamespaceSubscriber - // ... остальные поля -} - -type nsShard struct { - mu sync.RWMutex - records map[string]NamespaceRecord // только NS принадлежащие этому шарду -} - -func shardIndex(ns string) int { - h := fnv.New32a() - h.Write([]byte(ns)) - return int(h.Sum32()) % numShards -} -``` - -`Upsert(ns, ...)` → берёт lock только шарда `shardIndex(ns)`. -`Get(ns)` → RLock только нужного шарда. -`Snapshot()` → **последовательно** берёт RLock каждого шарда, копирует, освобождает, переходит к следующему. N=16 последовательных lock-acquisitions. - -### Сохранение интерфейса - -Публичный интерфейс `NamespaceManager` (Upsert, Get, Snapshot, Subscribe, Dispatch) не меняется. Подписчики (`NamespaceSubscriber`) не меняются. - -### Анализ выгоды - -При 10 ns/час churn: **одно upsert каждые 6 минут**. Текущий bottleneck — не mutex, а: -1. Synchronous subscriber dispatch (каждый делает k8s API calls) -2. Informer resync thundering herd -3. AdoptExistingResources race - -Sharded lock убирает конкуренцию за mutex при **параллельных per-namespace операциях**. Но `dispatch()` сам снимает/берёт lock несколько раз — sharding не помогает здесь (dispatch по одному NS всегда один шард). - -`Snapshot()` становится чуть медленнее (16 lock-acquisitions вместо 1 RLock) при маленьком числе NS, и сопоставима при большом. - -### Вердикт - -**Технически реализуемо с сохранением интерфейса. Не оправдано при текущей нагрузке.** - -Sharded mutex даст реальный выигрыш только если `Upsert` и `Get` вызываются **параллельно для разных NS** с частотой > 100 ops/sec. При 10 ns/час это недостижимо. Реальные bottleneck-и — в subscriber dispatch и informer lifecycle, не в mutex. - -Приоритет вместо sharding: -1. Сделать subscriber dispatch **параллельным** (goroutine per subscriber с errgroup) — немедленное ускорение онбординга. -2. Добавить `RemoveNamespace` в `DefaultNSResolver` — закрывает класс dirty-state багов. -3. Добавить reconcile-очередь (см. п. 3) — закрывает stuck-failed. - -Sharded lock — в backlog, актуально при > 500 concurrent tenant с > 1 onboarding/sec. +**Вердикт:** не оправдано при текущей нагрузке. Реальные bottleneck-и — AdoptExistingResources race и stuck-failed, не mutex. Sharded mutex — в backlog, актуально при >500 concurrent tenant с >1 onboarding/sec. diff --git a/doc/FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md b/doc/FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md new file mode 100644 index 00000000..848b141b --- /dev/null +++ b/doc/FORENSIC_ARCHITECTURE_AUDIT_LEGACY_2026-05.md @@ -0,0 +1,385 @@ +# Forensic Architecture Audit: Fission Fork (feature/multitenant, May 2026) + +--- + +## Architectural Decisions (реально принятые) +- **Dynamic Namespace Discovery**: Введён механизм динамического обнаружения и подключения tenant-namespace через label `fission.io/managed=true` (см. `pkg/utils/namespace_manager.go`, `pkg/executor/multitenant/ns_watcher.go`). +- **Namespace Lifecycle Management**: Весь жизненный цикл namespace теперь централизован через интерфейс `NamespaceManager` с подписчиками (executor, router, buildermgr). +- **Decoupled Registration**: Каждый компонент (executor, router, buildermgr) подписывается как subscriber и реализует свою логику инициализации/чистки ресурсов при появлении/удалении namespace. +- **Backward Compatibility**: Сохраняется поддержка статического списка через env (`FISSION_RESOURCE_NAMESPACES`), но теперь он расширяется динамически. +- **No-Restart Onboarding**: Добавление нового tenant не требует рестарта pod-ов — watcher реагирует на label, триггерит регистрацию во всех подсистемах. +- **RBAC/SA Provisioning**: Автоматическое создание service account и RBAC для новых namespace (см. `EnsureNamespaceSA`). +- **Informer Factories Per Namespace**: Для каждого нового namespace создаются отдельные informer factory для CRD и core-ресурсов. +- **Explicit Namespace Removal Strategy**: Поддержка двух стратегий удаления: track-only (по умолчанию) и dispatch-remove (с вызовом OnNamespaceRemove у подписчиков). + +## Core Complexity Centers +- **NamespaceManager & Watcher**: Центр всей динамики — сложная координация событий, фаз, подписчиков, race-conditions. +- **ExecutorType Subsystems**: Poolmgr, NewDeploy, Container — каждый хранит собственное состояние, кэш, логику adoption и reaping. +- **Informer Lifecycle**: Динамическое создание/удаление informer-ов на лету для каждого namespace. +- **FunctionServiceCache**: Кэширование и lifecycle function pod-ов, синхронизация с событиями из разных источников. + +## Hidden Coupling & Accidental Complexity +- **Implicit Contract**: Все компоненты обязаны корректно реализовать NamespaceSubscriber — нарушение приводит к silent drift. +- **Global vs Local State**: Есть глобальный NamespaceResolver и локальные состояния в каждом executor type — возможны рассинхронизации. +- **Deduplication Responsibility**: Deduplication namespace размазан между глобальным резолвером и локальными структурами. +- **Event Handler Ordering**: Порядок подписчиков влияет на фазу и side-effects, но не гарантируется явно. +- **RBAC Drift**: Provisioning SA/RBAC делается в одном месте, но cleanup — в другом, возможны dangling ресурсы. + +## Iterative Growth +- **Layered Refactor**: Ветка развивается через серию малых шагов (см. doc/thinking/2026-04-26-namespace-manager-step*.md), каждый шаг — отдельный инвариант. +- **Hybrid Model**: Некоторое время coexist старый статический и новый динамический pipeline, с явным разделением путей. +- **Feature Flags via Env**: Многое управляется через env-переменные, что позволяет поэтапно включать/выключать новые механики. + +## Workaround-Driven Decisions +- **Track-Only Removal**: По умолчанию удаление namespace не вызывает cleanup в подписчиках — workaround против race-condition при массовых удалениях. +- **Manual Adoption**: При старте executor-ы делают adopt orphaned ресурсов (pods, deployments) — workaround для несовершенного lifecycle. +- **Explicit Reaper Loops**: Для чистки orphaned объектов используются отдельные циклы (object reaper), а не event-driven подход. + +## Fragile Operational Components +- **Informer Factory Lifecycle**: Ошибки в динамическом создании/удалении informer-ов приводят к memory leak или stale watchers. +- **RBAC/SA Drift**: Неконсистентность между созданием и удалением сервисных аккаунтов и ролей. +- **Cache Invalidation**: FunctionServiceCache может рассинхронизироваться при сбоях в event flow. +- **Adoption Loops**: AdoptExistingResources может не покрыть все edge-case, особенно при race между startup и watcher. + +## Poor Scalability Risks +- **Informer Explosion**: На сотнях/тысячах namespace число informer-ов и goroutine растёт линейно, возможен memory/FD exhaustion. +- **Synchronous Dispatch**: Все подписчики вызываются синхронно, при долгой инициализации одного — блокируются остальные. +- **Centralized Locking**: NamespaceManager держит глобальный mutex на все операции — bottleneck при высокой churn rate. +- **No Sharding**: Нет горизонтального масштабирования NamespaceManager — всё в одном процессе. + +## Future Maintenance Problems +- **Hidden State Machines**: Фазы namespace и частей (part state) реализованы неявно, без явной state machine — сложно дебажить stuck state. +- **Implicit Error Handling**: Ошибки в подписчиках часто логируются, но не эскалируются — возможна silent failure. +- **Contract Drift**: Любое изменение интерфейса NamespaceSubscriber требует синхронного обновления всех компонентов. +- **Complex Test Surface**: Много интеграционных точек, сложно покрыть тестами все сценарии гонок и отказов. + +## Deepest Upstream Divergence +- **Полная замена статической модели discovery на динамическую через watcher и NamespaceManager.** +- **Весь lifecycle tenant-namespace теперь event-driven, а не env-driven.** +- **Введён централизованный интерфейс подписки на события namespace для всех core-компонентов.** +- **Механика adopt orphaned ресурсов и явная поддержка rollback/cleanup.** + +## Surprisingly Mature Parts +- **Интерфейс NamespaceManager**: Чётко выделен, покрыт тестами, поддерживает snapshot, summary, phase tracking. +- **Event Handler Abstraction**: Все watcher-ы используют единый event handler contract, легко расширять. +- **Backward Compatibility Layer**: Старый pipeline не сломан, coexist с новым. +- **Документация и коммиты**: Подробные шаги, объяснения, reasoning — видно зрелый инженерный подход. + +## Risky / Hard-to-Maintain Decisions +- **Informer Lifecycle Management**: Очень сложно гарантировать отсутствие leak/stale при динамике. +- **Centralized Mutex**: Один mutex на NamespaceManager — риск блокировок. +- **Manual Adoption**: AdoptExistingResources — временное решение, не покрывает все сценарии. +- **No Explicit State Machine**: Фазы и переходы не формализованы, возможны stuck state. +- **Eventual Consistency**: Нет гарантии моментальной консистентности между компонентами. + +--- + +## Multi-Tenancy, Isolation, Orchestration, Lifecycle, State, Reconciliation +- **Multi-Tenancy**: Реализовано через label-based discovery, каждый tenant — отдельный namespace, все ресурсы изолированы на уровне k8s. +- **Isolation Model**: Namespace-level isolation, автоматическое создание SA/RBAC, informer-ы и кэш на каждый tenant. +- **Orchestration**: NamespaceManager + подписчики — централизованный event bus для всех core-компонентов. +- **Lifecycle Management**: Поддержка всех фаз (discovered, registering, active, deregistering, removed, failed), но state machine неявная. +- **State Handling**: Гибрид глобального и локального состояния, возможны рассинхронизации. +- **Reconciliation Logic**: Каждый компонент реализует свою reconcile-логику через подписку на события. +- **Controller Complexity**: Высокая, много слоёв абстракции, много точек гонок. +- **Deployment Reproducibility**: Helm-чарты поддерживают все новые env, backward compatibility сохранён. +- **Operational Burden**: Высокий — требуется мониторинг leak, race, orphaned ресурсов, ручной контроль за adoption. + +--- + +## Engineering Maturity, Complexity, Maintainability Horizon +- **Maturity**: Архитектурно зрелый, хорошо документированный, с явным reasoning и поэтапным внедрением. +- **Complexity**: Высокая, особенно в динамике и синхронизации между компонентами. +- **Maintainability**: Среднесрочная — без явной state machine и горизонтального масштабирования возможны проблемы при росте нагрузки. +- **Production-Grade**: Ближе к production-grade platform engineering, чем к эксперименту, но требует доработки по масштабированию и явной формализации state transitions. + +--- + +## Architectural Drift / Entropy / Hazards +- **Drift**: Возможен drift между глобальным и локальным состоянием, если подписчики реализованы несимметрично. +- **Entropy**: Много точек входа, implicit contract, нет явной state machine — сложность будет расти. +- **Hazards**: Memory leak, race-condition, orphaned ресурсы, silent failure при ошибках в подписчиках. + +--- + +## Summary +Этот форк — зрелая попытка перевести Fission на event-driven multi-tenant архитектуру с динамическим discovery и централизованным lifecycle management. Основные сложности и риски — в управлении состоянием, синхронизации и масштабируемости. Требует дальнейшей формализации state machine, горизонтального масштабирования и усиления тестового покрытия для production-grade эксплуатации. + +--- + +# Deep Risk Analysis (May 2026) + +> Конкретные сценарии отказа, оценка при 50–100 tenant, предложения по исправлению. + +--- + +## 1. Сценарии отказа для каждого "Risky Decision" + +### 1.1 Informer Lifecycle Management + +**Сценарий: повторная регистрация namespace через relabel** + +1. Оператор снимает label `fission.io/managed=true` с namespace `tenant-42`. +2. Namespace-watcher вызывает `HandleWatcherNamespaceRemoval()`. Стратегия `TrackOnly`: NamespaceManager помечает запись как `removed` и **не вызывает** `OnNamespaceRemove` у подписчиков. +3. Informer-ы executor (gpm, newdeploy) и router продолжают работать — pool для tenant-42 жив, функции маршрутизируются. +4. Оператор возвращает label — kubernetes генерирует `MODIFIED`-событие. +5. `RunManagedNamespaceWatcher` (resync 30 мин) может не вызвать Add снова для уже известного NS. +6. **Router**: `AddNamespace` вызывает `DefaultNSResolver().AddNamespace(ns)`. Глобальный resolver уже содержит tenant-42 (его никто не удалял из-за track-only) → возвращает `false` → router делает **early return без создания новых informer-ов** (строка 460 `httpTriggers.go`). Router считает namespace активным (старые informer-ы ещё работают) — но если они были остановлены контекстом — тихое 404. +7. **Executor**: `gpm.AddNamespace` проверяет `poolPodC.envLister[ns]` — если старый lister жив, возвращает nil сразу (дедупликация). Всё выглядит нормально, но фактически используются **устаревшие informer-ы** с застрявшим кэшем. + +**Итог**: relabel-цикл создаёт phantom-состояние: компоненты думают что NS активен, но его lifecycle разорван. + +--- + +### 1.2 Centralized Mutex + +**Сценарий: высокая churn + concurrent Snapshot** + +`dispatch()` снимает write-lock перед вызовом каждого subscriber-а, затем берёт его снова для следующего. Структура: + +``` +mu.Lock() → читаем список subs → +mu.Unlock() → вызываем handler(sub1) [k8s API call, может занять сотни мс] +mu.Lock() → читаем следующий sub → +mu.Unlock() → вызываем handler(sub2) +``` + +Параллельно: router каждые 20 мс делает `syncTriggers()` → `updateRouter()` → итерирует `snapshotFuncInformers()` → берёт `informerMu.RLock`. Это другой mutex, но `DefaultNSResolver().Snapshot()` вызывается из `idleObjectReaper` каждые 5 сек под глобальным `RWMutex` NamespaceManager. + +При 100 tenant с churn 10 ns/час: в среднем каждые 6 мин добавляется namespace. Само по себе безвредно. Но при пике (батч-онбординг 10 tenant за 1 минуту): `dispatch()` держит write-lock с паузами на unlock/relock для каждого subscriber × 10 параллельных dispatch → конкуренция за mutex возрастает. `Snapshot()` в `idleObjectReaper` (каждые 5 сек) и в `AdoptExistingResources` (каждый рестарт) будут ждать. + +**Итог**: не deadlock, но latency spike на Snapshot на старте и при батч-онбординге — 200–500 мс при 10+ concurrent dispatch. + +--- + +### 1.3 Manual Adoption (AdoptExistingResources) + +**Сценарий: гонка adoption vs watcher** + +1. Executor стартует. `AdoptExistingResources` запускается, берёт `DefaultNSResolver().Snapshot()` — snapshot содержит только статические NS из `FISSION_RESOURCE_NAMESPACES`. +2. Параллельно запускается `RunManagedNamespaceWatcher`. Watcher вызывает `BootstrapAndDispatch()`, который регистрирует managed NS и вызывает `registerNamespace()` у executor-подписчика. +3. `registerNamespace()` вызывает `DefaultNSResolver().AddNamespace(ns)` (глобальный guard), затем `gpm.AddNamespace()`. +4. **Но `AdoptExistingResources` уже завершила свой loop** — managed NS не попал в snapshot. Orphaned pods в tenant NS не приняты. +5. Функции в этих pod-ах будут вызываться ещё раз через cold start — лишний latency spike и потеря статуса `instanceID` у подов (старый instanceID в annotation не перезаписан → `CleanupOldExecutorObjects` сочтёт их orphaned → удалит). + +**Hardcoded 30s timeout**: `AdoptExistingResources` в poolmgr не имеет явного timeout, но `k8sCache.WaitForCacheSync` в `Run()` блокирует до готовности — только после этого запускается `service()`. Если namespace watcher опередил, poolmgr получит env-события до того как `AdoptExistingResources` завершится → гонка на `gpm.pools` map (не защищена mutex вне `service()` goroutine). + +--- + +### 1.4 No Explicit State Machine + +**Сценарий: stuck в `failed` без auto-recovery** + +1. Namespace `tenant-99` помечен `fission.io/managed=true`. +2. `registerNamespace()` вызывает `EnsureNamespaceSA()` — Kubernetes API momentarily unavailable (503). +3. `EnsureNamespaceSA()` возвращает ошибку → вызывающий код (предположительно) пишет в лог и помечает часть как `NamespacePartStateFailed`. +4. `deriveNamespacePhase()` выставляет namespace в `NamespacePhaseFailed`. +5. **Нет reconcile-цикла**: нет горутины, которая периодически проверяет failed namespace и пытается повторить. Phase останется `failed` до рестарта процесса. +6. Router был вызван следующим в цепочке dispatch. Т.к. dispatch вызывается подписчики последовательно без barrier, router **уже создал свои informer-ы** до того как executor завершился с ошибкой. +7. **Dirty state**: router видит `tenant-99` как активный (informer-ы есть), executor — нет (SA/RBAC не создан). Любой вызов функции из tenant-99 → executor не может специализировать pod (нет fetcher SA) → 503. + +Лог покажет ошибку, но namespace останется в `failed` навсегда (до рестарта). Оператор не получит никакого k8s-статуса — ни condition на Namespace объекте, ни event. + +--- + +### 1.5 Eventual Consistency + +**Сценарий: HTTPTrigger создан в окне до ready informer** + +1. Tenant создаёт namespace с label → namespace добавляется в NamespaceManager. +2. `dispatch()` вызывает router subscriber → `AddNamespace()`: + ```go + k8sCache.WaitForCacheSync(ctx.Done(), triggerInf.HasSynced, funcInf.HasSynced) + ts.syncTriggers() + ``` + Router ждёт sync и перестраивает роутинг. Это занимает несколько секунд. +3. Tenant **немедленно** после создания namespace создаёт HTTPTrigger через API. +4. Если trigger создан **до** завершения `WaitForCacheSync` в router → informer ещё не синхронизирован, но trigger уже в etcd. +5. После sync informer получит это событие через `AddFunc` → `syncTriggers()`. Это нормально. +6. **Проблема в другом**: `dispatch()` вызывает подписчиков **последовательно**. Если executor (первый в списке) занимается `EnsureNamespaceSA` + `registerExecutorTypes` (10–30 сек при медленном API) → router subscriber не вызывается всё это время. HTTPTrigger, созданный в этом окне, попадёт в informer, но router ещё не начал слушать → `AddFunc` для этого trigger не вызовется никогда (resync через 30 мин). +7. Результат: trigger существует в etcd, но **отсутствует в роутере 30 минут**. + +--- + +## 2. Анализ при 50–100 tenant с churn 10 ns/час + +### Informer Explosion + +При 100 активных tenant: +- **Executor (poolmgr)**: 1 `SharedInformerFactory` (Fission CRD) + 1 `SharedInformerFactory` (k8s pods/RS) на NS = 200 factory. Каждая factory запускает горутины на каждый informer (~3–5 горутин). **~600–1000 goroutine** только от poolmgr. +- **Executor (newdeploy)**: аналогично — ещё 200 factory, ~600 goroutин. +- **Router**: 1 factory на NS = 100 factory, ~200 goroutин. +- **buildermgr**: 1 factory на NS = 100 goroutин. + +Итого: **~1500–2000 goroutine** только от informer-ов. При пике churn (10 ns/час) — каждые 6 минут добавляется NS, создаётся ~20 новых горутин, они не убираются при track-only removal. + +При **100 NS × 30 мин resync**: каждые 30 мин каждый informer делает LIST всех объектов в своём NS. 100 × 5 informer-типов × LIST = **500 concurrent LIST-запросов** к Kubernetes API раз в 30 минут — возможный thundering herd. + +### Stuck Failed State + +10 ns/час churn с 1% API error rate = ~2.4 failed namespace/сутки. Каждый остаётся в `failed` навсегда. За 30 дней = ~72 "мёртвых" записи в NamespaceManager. `Snapshot()` возвращает их в `idleObjectReaper` → лишние LIST к k8s API для несуществующих/неактивных NS → ошибки, логи, load. + +### AdoptExistingResources Race + +Каждый рестарт executor-а — race. При rolling update в k8s (новый pod стартует, старый ещё жив): оба executor-а параллельно делают `AdoptExistingResources` → оба патчат `instanceID` на одних и тех же pod-ах → `CleanupOldExecutorObjects` нового экземпляра удаляет pod-ы старого (ожидаемо), но при race может удалить pod, который новый экземпляр уже adoptировал. + +### Router Dedup Gap — критический сценарий при рестарте + +При рестарте executor + router одновременно: +1. `FISSION_RESOURCE_NAMESPACES` содержит `fission-fn` (статический NS). +2. `namespace.go` `init()` добавляет его в `DefaultNSResolver`. +3. `BootstrapAndDispatch()` в NamespaceManager вызывает dispatch для всех managed NS, включая `fission-fn`. +4. **Router** `AddNamespace("fission-fn")` → `DefaultNSResolver().AddNamespace("fission-fn")` → **false** (уже добавлен в `init()`!) → **early return, informer для fission-fn НЕ создан**. +5. Executor (gpm, newdeploy) — используют own dedup (envLister/deplLister), `fission-fn` там нет → создают informer. +6. Router слеп к HTTPTrigger и Function событиям из `fission-fn` при динамическом пути. Спасает только то, что `GetInformersForNamespaces` вызывается в `MakeHTTPTriggerSet` при старте — но только для NS из env. + +**Вывод**: если `fission-fn` включён в `FISSION_RESOURCE_NAMESPACES` И помечен `fission.io/managed=true` — возможна ситуация, когда после рестарта router использует startup-informer, а executor использует watcher-informer с другим lifecycle → рассинхронизация при следующем relabel-цикле. + +--- + +## 3. Минимальное изменение: явная state machine без полного рефакторинга + +Текущая проблема: `failed` namespace остаётся в `failed` навсегда — нет retry. + +**Изменение**: добавить reconcile-очередь в `inMemoryNamespaceManager` без изменения публичного интерфейса. + +```go +// В inMemoryNamespaceManager добавить: +type reconcileRequest struct { + ns string + attempt int +} + +reconcileQueue chan reconcileRequest // небуферизованный или с буфером 64 + +// В MarkPartFailed (или в dispatch при возврате ошибки от subscriber): +func (m *inMemoryNamespaceManager) enqueueReconcile(ns string, attempt int) { + select { + case m.reconcileQueue <- reconcileRequest{ns: ns, attempt: attempt}: + default: // уже в очереди, skip + } +} + +// Новая горутина, запускается в BootstrapAndDispatch или отдельным методом: +func (m *inMemoryNamespaceManager) RunReconciler(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case req := <-m.reconcileQueue: + if req.attempt >= 5 { // max retries + m.logger.Error("namespace reconcile exhausted", zap.String("ns", req.ns)) + continue + } + backoff := time.Duration(1< 100 ops/sec. При 10 ns/час это недостижимо. Реальные bottleneck-и — в subscriber dispatch и informer lifecycle, не в mutex. + +Приоритет вместо sharding: +1. Сделать subscriber dispatch **параллельным** (goroutine per subscriber с errgroup) — немедленное ускорение онбординга. +2. Добавить `RemoveNamespace` в `DefaultNSResolver` — закрывает класс dirty-state багов. +3. Добавить reconcile-очередь (см. п. 3) — закрывает stuck-failed. + +Sharded lock — в backlog, актуально при > 500 concurrent tenant с > 1 onboarding/sec.