Files
fission-src/doc/FORENSIC_ARCHITECTURE_AUDIT.md
T
“Naeel” 4eedf95f5c fix(namespace): executor/router/buildermgr RemoveNamespace + per-NS informer lifecycle
- Add RemoveNamespace(ctx, ns) to executortype.ExecutorType interface
- Implement RemoveNamespace in poolmgr, newdeploy, container executor types
- Add per-namespace context cancellation (nsCancels map) in all three types so
  informer factories are stopped when namespace is removed (fixes goroutine leak)
- Add PoolPodController.RemoveNamespace to clear envLister/podLister maps
- Add deregisterNamespace() in executor multitenant subscriber
- Switch executor/router/buildermgr watcher strategy from TrackOnly to DispatchRemove
  so RemoveFunc is called when fission.io/managed label is removed
- Add RemoveFunc to executor/router/buildermgr namespace subscribers
- Add RemoveNamespace to environmentWatcher and packageWatcher with per-NS cancel
- Add RemoveNamespace to HTTPTriggerSet: cancels informers, removes from maps, calls syncTriggers
- Fix ns_watcher_test.go fakeExecutorType to implement new RemoveNamespace method

Fixes:
- Executor dedup gap: re-added namespace was silently skipped (envLister/deplLister still present)
- Goroutine/FD leak: old informer factories ran forever after namespace removal
- Router stale routes: HTTPTriggers for removed namespace stayed in routing table
2026-05-18 09:04:13 +04:00

386 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Анализ при 50100 tenant с churn 10 ns/час
### Informer Explosion
При 100 активных tenant:
- **Executor (poolmgr)**: 1 `SharedInformerFactory` (Fission CRD) + 1 `SharedInformerFactory` (k8s pods/RS) на NS = 200 factory. Каждая factory запускает горутины на каждый informer (~35 горутин). **~6001000 goroutine** только от poolmgr.
- **Executor (newdeploy)**: аналогично — ещё 200 factory, ~600 goroutин.
- **Router**: 1 factory на NS = 100 factory, ~200 goroutин.
- **buildermgr**: 1 factory на NS = 100 goroutин.
Итого: **~15002000 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<<req.attempt) * time.Second // 1, 2, 4, 8, 16 сек
time.AfterFunc(backoff, func() {
// Повторить dispatch только для failed-частей:
m.mu.RLock()
rec, ok := m.records[req.ns]
m.mu.RUnlock()
if !ok || rec.Phase != NamespacePhaseFailed {
return // уже исправлено или удалено
}
// Вызвать только тех подписчиков, у кого часть в FailedState:
m.dispatchRetry(ctx, req.ns, req.attempt+1)
})
}
}
}
```
**Изменения интерфейса**: `NamespaceManager` получает метод `RunReconciler(ctx)` — добавляется в интерфейс, но не breaking change для существующих вызывающих (можно добавить как опциональный метод или вызвать из `BootstrapAndDispatch`).
**Что НЕ меняется**: `NamespaceSubscriber`, `NamespaceRecord`, публичные методы `Upsert`/`Snapshot`/`Subscribe` — всё прежнее.
---
## 4. Track-Only Removal: скрытые допущения и dirty state
### Допущение 1: `DefaultNSResolver` — только append
`pkg/utils/namespace.go`: метод `AddNamespace` добавляет NS в глобальный map, метода `RemoveNamespace` не существует. Последствия:
- 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** если имена совпадают.
---
## 5. Оценка замены centralized mutex на sharded lock
### Техническая реализация (feasible)
```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.