Files
fission-src/doc/2026-05-18-namespace-lifecycle-hardening-impl.md
T

25 KiB
Raw Blame History

Namespace Lifecycle Hardening — Implementation Notes

Дата: 2026-05-18

Ветка: fix/namespace-lifecycle-hardening

Коммиты: 3b93c5dc (предыдущая сессия) → 4eedf95f (эта сессия)


1. Контекст: что было сделано до этой сессии

Предыдущие правки (коммит 3b93c5dc)

  1. RemoveNamespace(ns string) bool — добавлен в NamespaceResolver (pkg/utils/namespace.go).
    HandleWatcherNamespaceRemoval теперь вызывает его при любой стратегии, очищая глобальный resolver. Это исправляет дедупликацию router/buildermgr при re-add NS.

  2. Параллельный dispatch() — pkg/utils/namespace_manager.go: заменён последовательный обход подписчиков на параллельный с sync.WaitGroup. Исправляет 30-минутное окно, когда HTTPTrigger не видел namespace из-за того что обход был последовательным.

  3. RunReconciler() — добавлен в NamespaceManager interface и реализован в inMemoryNamespaceManager. Каждые 30 секунд сканирует namespace-ы в фазе NamespacePhaseFailed и вызывает DispatchResync. Исправляет постоянно stuck-failed namespace при транзиентных k8s API ошибках.

Что оставалось нерешённым (из аудита)

Три проблемы, зафиксированные в FORENSIC_ARCHITECTURE_AUDIT.md:

Проблема 1 — Executor dedup gap (Critical)
При удалении NS и повторном добавлении executor молча пропускал его.
Причина: gpm.poolPodC.envLister[ns] и deploy.deplLister[ns] проверялись как дедупликация в AddNamespace, но никогда не очищались при удалении NS.
Результат: повторно добавленный namespace не получал informers в executor → функции не запускались.

Проблема 2 — Goroutine/FD leak (High)
При удалении NS старые informer factories продолжали работать (goroutines, file descriptors, LIST-запросы к k8s API каждые 30 минут).
Причина: informers запускались с ctx.Done() родительского контекста всего процесса, без механизма per-NS остановки.

Проблема 3 — Router stale routes (Medium)
После удаления NS router продолжал держать HTTPTrigger routes для этого namespace.
Причина: triggerInformer[ns] и funcInformer[ns] не чистились, syncTriggers() не вызывался.


2. Анализ перед реализацией

2.1 Чтение интерфейса ExecutorType

Файл: pkg/executor/executortype/executortype.go

// До правки — нет RemoveNamespace
AddNamespace(ctx context.Context, ns string, mgr manager.Interface) error
}

Подтверждено: ни grep, ни LSP не нашли RemoveNamespace в executor types.

2.2 Анализ механизма дедупликации по каждому executor type

poolmgr (gpm.go строка 807):

if _, ok := gpm.poolPodC.envLister[ns]; ok {
    return nil // already registered
}

Деdup через PoolPodController.envLister[ns] — локальная карта, не связана с глобальным resolver.

newdeploy (newdeploymgr.go строка 917):

if _, ok := deploy.deplLister[ns]; ok {
    return nil // already registered
}

Деdup через deploy.deplLister[ns].

container (containermgr.go строка 805):

if _, ok := caaf.deplLister[ns]; ok {
    return nil // already registered
}

Деdup через caaf.deplLister[ns].

router (httpTriggers.go строка 461):

if !utils.DefaultNSResolver().AddNamespace(ns) {
    return nil // already registered
}

Деdup через глобальный resolver — уже починен предыдущим коммитом (RemoveNamespace в resolver).

buildermgr envwatcher (envwatcher.go строка 506):

if _, exists := envw.envWatchInformer[ns]; exists {
    return
}

Деdup через envw.envWatchInformer[ns].

buildermgr pkgwatcher (pkgwatcher.go строка 337):

if _, exists := pkgw.pkgInformer[ns]; exists {
    return
}

Деdup через pkgw.pkgInformer[ns].

2.3 Анализ стратегии удаления

NewDefaultManagedNamespaceWatcherConfig создаёт конфиг с RemovalStrategy: NamespaceRemovalStrategyTrackOnly.
При TrackOnly — HandleWatcherNamespaceRemoval вызывает DefaultNSResolver().RemoveNamespace() (наш предыдущий фикс), но не вызывает manager.DispatchRemove() → subscriber.OnNamespaceRemove() → RemoveFunc не срабатывает.

Для вызова RemoveFunc нужна стратегия DispatchRemove.

2.4 Решение: per-namespace context cancellation

Informers запускаются через factory.Start(ctx.Done()). Стандартный способ остановить отдельный informer — отменить контекст, с которым он запущен.

Решение:

nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done())  // вместо ctx.Done()

При RemoveNamespace:

if cancel, ok := gpm.nsCancels[ns]; ok {
    cancel()  // останавливает goroutines informer factories
    delete(gpm.nsCancels, ns)
}

Это чисто и не требует изменения k8s client-go.

2.5 Mutex и thread-safety

Существующий код в executor types не защищает lister maps мьютексами. Записи в них происходят только при AddNamespace (из subscriber goroutine). Добавление RemoveNamespace добавляет ещё одну запись из той же goroutine. Race condition с event handlers (которые читают эти maps) — известное ограничение существующего дизайна, не добавляем мьютексы чтобы не выходить за рамки задачи.

HTTPTriggerSet уже имеет informerMu sync.RWMutex — его и используем в RemoveNamespace при удалении из triggerInformer/funcInformer.


3. Реализация — пошаговое описание

Шаг 1: pkg/executor/executortype/executortype.go

Добавлен метод в ExecutorType interface:

// RemoveNamespace deregisters a namespace from the executor, cancelling its
// informer goroutines and clearing dedup state so that a re-add works correctly.
// Called when a Namespace with label fission.io/managed=true is removed.
RemoveNamespace(ctx context.Context, ns string) error

Почему: Все три executor type реализуют этот интерфейс. Добавление в интерфейс гарантирует, что новый тип executor не забудет реализовать метод (компилятор поймает).


Шаг 2: pkg/executor/executortype/poolmgr/gpm.go

2a. Добавлено поле в struct:

// nsCancels holds per-namespace context cancel functions so informer
// factories started in AddNamespace can be stopped on RemoveNamespace.
nsCancels map[string]context.CancelFunc

2b. Инициализация в MakeGenericPoolManager:

nsCancels: make(map[string]context.CancelFunc),

2c. Изменение в AddNamespace: вместо ctx.Done() передаём nsCtx.Done():

nsCtx, nsCancel := context.WithCancel(ctx)
gpm.nsCancels[ns] = nsCancel
finformer.Start(nsCtx.Done())
gpmInformer.Start(nsCtx.Done())

2d. Новый метод RemoveNamespace:

func (gpm *GenericPoolManager) RemoveNamespace(ctx context.Context, ns string) error {
    if ns == "" { return nil }
    gpm.logger.Info("RemoveNamespace: cleaning up namespace (poolmgr)", ...)
    if cancel, ok := gpm.nsCancels[ns]; ok {
        cancel()
        delete(gpm.nsCancels, ns)
    }
    delete(gpm.podLister, ns)
    delete(gpm.podListerSynced, ns)
    gpm.poolPodC.RemoveNamespace(ns)  // очищает envLister/podLister в PoolPodController
    return nil
}

Шаг 3: pkg/executor/executortype/poolmgr/poolpodcontroller.go

Добавлен метод, очищающий lister maps в PoolPodController:

func (p *PoolPodController) RemoveNamespace(ns string) {
    delete(p.envLister, ns)
    delete(p.envListerSynced, ns)
    delete(p.podLister, ns)
    delete(p.podListerSynced, ns)
    p.logger.Info("PoolPodController.RemoveNamespace: cleared lister state", ...)
}

Почему отдельный метод: PoolPodController — отдельная структура внутри poolmgr. Доступ к её полям из GenericPoolManager.RemoveNamespace требовал бы либо экспорта полей, либо метода. Метод — чище.


Шаг 4: pkg/executor/executortype/newdeploy/newdeploymgr.go

Аналогично gpm:

  • Добавлен nsCancels map[string]context.CancelFunc в struct NewDeploy
  • Инициализирован в MakeNewDeploy
  • AddNamespace переключён на nsCtx.Done()
  • Добавлен RemoveNamespace очищающий deplLister, deplListerSynced, svcLister, svcListerSynced

Шаг 5: pkg/executor/executortype/container/containermgr.go

Аналогично. Struct Container получил nsCancels. AddNamespace использует nsCtx.Done(). RemoveNamespace очищает deplLister, deplListerSynced, svcLister, svcListerSynced.


Шаг 6: pkg/executor/multitenant/namespace_subscriber.go

Добавлен RemoveFunc в NamespaceSubscriberFuncs:

RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
    return deregisterNamespace(ctx, logger, record.Name, executorTypes)
},

deregisterNamespace итерирует все executor types и вызывает et.RemoveNamespace(ctx, ns).


Шаг 7: pkg/executor/multitenant/ns_watcher.go

7a. Добавлен deregisterNamespace:

func deregisterNamespace(ctx, logger, ns, executorTypes) error {
    var joinErr error
    for _, et := range executorTypes {
        if err := et.RemoveNamespace(ctx, ns); err != nil {
            joinErr = errors.Join(joinErr, err)
        }
    }
    logger.Info("multitenant.NSWatcher: deregistered namespace", ...)
    return joinErr
}

7b. Изменён StartNSWatcher: стратегия TrackOnly → DispatchRemove:

config := utils.NewDefaultManagedNamespaceWatcherConfig(...)
config.RemovalStrategy = utils.NamespaceRemovalStrategyDispatchRemove

Почему: Без DispatchRemove RemoveFunc подписчика никогда не вызывается. TrackOnly вызывает только DefaultNSResolver().RemoveNamespace() (что сделано в HandleWatcherNamespaceRemoval), но не диспетчирует событие подписчикам.


Шаг 8: pkg/buildermgr/envwatcher.go

  • Добавлен nsCancels map[string]context.CancelFunc в struct environmentWatcher
  • Инициализирован в makeEnvironmentWatcher (там же где envWatchInformer)
  • AddNamespace переключён на per-NS context:
    nsCtx, nsCancel := context.WithCancel(ctx)
    envw.nsCancels[ns] = nsCancel
    factory.Start(nsCtx.Done())
    
  • Добавлен RemoveNamespace(ns string):
    func (envw *environmentWatcher) RemoveNamespace(ns string) {
        if cancel, ok := envw.nsCancels[ns]; ok { cancel(); delete(...) }
        delete(envw.envWatchInformer, ns)
    }
    

Ошибка при первой попытке: replace_string_in_file добавил nsCancels с тройным отступом (три таба вместо двух) и без закрывающего } struct literal — синтаксическая ошибка компиляции. Исправлено вторым вызовом replace.


Шаг 9: pkg/buildermgr/pkgwatcher.go

Аналогично envwatcher:

  • nsCancels в struct packageWatcher
  • Инициализация в makePackageWatcher
  • AddNamespace → per-NS ctx для fissionFactory.Start() и podFactory.Start()
  • RemoveNamespace(ns string) очищает pkgInformer[ns], podInformer[ns]

Шаг 10: pkg/buildermgr/namespace_subscriber.go

Добавлены два новых интерфейса:

type builderEnvNamespaceRemover interface {
    RemoveNamespace(ns string)
}
type builderPkgNamespaceRemover interface {
    RemoveNamespace(ns string)
}

Добавлен RemoveFunc:

RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
    deregisterBuilderNamespace(record.Name, envw, pkgw)
    return nil
},

deregisterBuilderNamespace через type assertion вызывает RemoveNamespace если интерфейс реализован:

func deregisterBuilderNamespace(namespace string, envw, pkgw) {
    utils.DefaultNSResolver().RemoveNamespace(namespace)
    if r, ok := envw.(builderEnvNamespaceRemover); ok { r.RemoveNamespace(namespace) }
    if r, ok := pkgw.(builderPkgNamespaceRemover); ok { r.RemoveNamespace(namespace) }
}

Почему type assertion: builderEnvNamespaceAdder — интерфейс-параметр функции NewNamespaceSubscriber. Вместо добавления RemoveNamespace в существующий интерфейс (что сломало бы тестовые фейки) используем опциональный интерфейс через type assertion.


Шаг 11: pkg/buildermgr/ns_watcher.go

Стратегия изменена на DispatchRemove аналогично executor.


Шаг 12: pkg/router/httpTriggers.go

12a. Добавлен nsCancels в struct:

// nsCancels holds per-namespace context cancel functions for informer lifecycle.
nsCancels map[string]context.CancelFunc

12b. Инициализация в makeHTTPTriggerSet:

nsCancels: make(map[string]context.CancelFunc),

12c. Изменён AddNamespace: per-NS ctx:

nsCtx, nsCancel := context.WithCancel(ctx)
ts.nsCancels[ns] = nsCancel
factory.Start(nsCtx.Done())
k8sCache.WaitForCacheSync(nsCtx.Done(), ...)  // тоже nsCtx

12d. Новый метод RemoveNamespace:

func (ts *HTTPTriggerSet) RemoveNamespace(ns string) {
    if cancel, ok := ts.nsCancels[ns]; ok { cancel(); delete(...) }
    ts.informerMu.Lock()
    delete(ts.triggerInformer, ns)
    delete(ts.funcInformer, ns)
    ts.informerMu.Unlock()
    ts.syncTriggers()  // немедленно перестраивает routing table без удалённого NS
}

Почему informerMu.Lock(): HTTPTriggerSet уже имеет informerMu sync.RWMutex для защиты triggerInformer/funcInformer. Используем его — не добавляем новые мьютексы.


Шаг 13: pkg/router/namespace_subscriber.go

Добавлен routerNamespaceRemover interface и RemoveFunc:

type routerNamespaceRemover interface {
    RemoveNamespace(ns string)
}

RemoveFunc: func(ctx context.Context, record utils.NamespaceRecord) error {
    if r, ok := ts.(routerNamespaceRemover); ok {
        r.RemoveNamespace(record.Name)
    }
    return nil
},

Шаг 14: pkg/router/ns_watcher.go

Стратегия → DispatchRemove.


Шаг 15: Тест-фейк pkg/executor/multitenant/ns_watcher_test.go

fakeExecutorType не реализовывал новый метод → ошибка компиляции:

*fakeExecutorType does not implement executortype.ExecutorType (missing method RemoveNamespace)

Добавлена заглушка:

func (f *fakeExecutorType) RemoveNamespace(ctx context.Context, ns string) error { return nil }

4. Результат компиляции и тестов

go build ./pkg/... ./cmd/...   → нет вывода (успех)

go test ./pkg/utils/...
      ./pkg/executor/...
      ./pkg/buildermgr/...
      ./pkg/router/...

ok    github.com/fission/fission/pkg/utils
ok    github.com/fission/fission/pkg/executor/executortype/newdeploy
ok    github.com/fission/fission/pkg/executor/executortype/poolmgr
ok    github.com/fission/fission/pkg/executor/fscache
ok    github.com/fission/fission/pkg/executor/multitenant
ok    github.com/fission/fission/pkg/executor/util
ok    github.com/fission/fission/pkg/buildermgr
ok    github.com/fission/fission/pkg/router

5. Схема потока при удалении NS (после всех правок)

k8s: Namespace label fission.io/managed=true удалён/NS удалён
    │
    ▼
ManagedNamespaceWatcher (DispatchRemove стратегия)
    │
    ├─► HandleWatcherNamespaceRemoval()
    │       DefaultNSResolver().RemoveNamespace(ns)  ← сброс глобального guard
    │       manager.DispatchRemove(ctx, ns)
    │
    ▼
inMemoryNamespaceManager.DispatchRemove()
    │
    ├─► goroutine: subscriber[executor].OnNamespaceRemove(record)
    │       deregisterNamespace(ctx, logger, ns, executorTypes)
    │           gpm.RemoveNamespace(ctx, ns)
    │               nsCancel()                    ← останавливает informer goroutines
    │               delete(podLister[ns])
    │               delete(podListerSynced[ns])
    │               poolPodC.RemoveNamespace(ns)
    │                   delete(envLister[ns])
    │                   delete(envListerSynced[ns])
    │                   delete(podLister[ns])
    │                   delete(podListerSynced[ns])
    │           deploy.RemoveNamespace(ctx, ns)
    │               nsCancel()
    │               delete(deplLister[ns])
    │               delete(deplListerSynced[ns])
    │               delete(svcLister[ns])
    │               delete(svcListerSynced[ns])
    │           container.RemoveNamespace(ctx, ns)
    │               nsCancel()
    │               delete(deplLister[ns])
    │               delete(svcLister[ns])
    │
    ├─► goroutine: subscriber[buildermgr].OnNamespaceRemove(record)
    │       deregisterBuilderNamespace(ns, envw, pkgw)
    │           DefaultNSResolver().RemoveNamespace(ns)  ← повторно (безопасно)
    │           envw.RemoveNamespace(ns)
    │               nsCancel()
    │               delete(envWatchInformer[ns])
    │           pkgw.RemoveNamespace(ns)
    │               nsCancel()
    │               delete(pkgInformer[ns])
    │               delete(podInformer[ns])
    │
    └─► goroutine: subscriber[router].OnNamespaceRemove(record)
            ts.RemoveNamespace(ns)
                nsCancel()                         ← останавливает triggerInf/funcInf goroutines
                informerMu.Lock()
                delete(triggerInformer[ns])
                delete(funcInformer[ns])
                informerMu.Unlock()
                syncTriggers()                     ← немедленно убирает routes для удалённого NS

6. Что НЕ было реализовано и почему

FunctionServiceCache.DeleteByNamespace(ns string) — не реализовано.

Причина: idleObjectReaper периодически вызывает IsValid() для всех записей. Для удалённого NS k8s API возвращает 404/403 → IsValid() вернёт false → запись будет удалена reaperом естественным образом. Это создаёт несколько минут "грязных" записей и 404 ошибки в логах, но не влияет на корректность: для удалённого NS новые запросы не придут (router очистил routes), а reaper уберёт старые записи.

Реализация DeleteByNamespace потребовала бы добавления namespace-индекса в byFunction/byAddress/byFunctionUID кэшах (нетривиально), или дорогого линейного прохода по всем записям. Не было делать без явного запроса.


7. Затронутые файлы (17 изменённых)

Файл Тип изменения
pkg/executor/executortype/executortype.go +метод в interface
pkg/executor/executortype/poolmgr/gpm.go +поле nsCancels, modify AddNamespace, +RemoveNamespace
pkg/executor/executortype/poolmgr/poolpodcontroller.go +RemoveNamespace
pkg/executor/executortype/newdeploy/newdeploymgr.go +поле nsCancels, modify AddNamespace, +RemoveNamespace
pkg/executor/executortype/container/containermgr.go +поле nsCancels, modify AddNamespace, +RemoveNamespace
pkg/executor/multitenant/namespace_subscriber.go +RemoveFunc
pkg/executor/multitenant/ns_watcher.go +deregisterNamespace, DispatchRemove
pkg/executor/multitenant/ns_watcher_test.go +RemoveNamespace в fakeExecutorType
pkg/buildermgr/namespace_subscriber.go +интерфейсы remover, +RemoveFunc, +deregisterBuilderNamespace
pkg/buildermgr/envwatcher.go +nsCancels, modify AddNamespace, +RemoveNamespace
pkg/buildermgr/pkgwatcher.go +nsCancels, modify AddNamespace, +RemoveNamespace
pkg/buildermgr/ns_watcher.go DispatchRemove
pkg/router/httpTriggers.go +nsCancels, modify AddNamespace, +RemoveNamespace
pkg/router/namespace_subscriber.go +routerNamespaceRemover, +RemoveFunc
pkg/router/ns_watcher.go DispatchRemove
doc/FORENSIC_ARCHITECTURE_AUDIT.md перемещён из корня (git rename)
doc/console-compat-2026-05-15.md создан (отдельная задача)

8. Ошибки в процессе

Ошибка 1: Синтаксическая ошибка в envwatcher.go

Что случилось: При попытке заменить блок инициализации struct добавился nsCancels: с тройным отступом и без закрывающей }:

// Стало (неверно):
		enableOwnerReferences:  utils.IsOwnerReferencesEnabled(),
			nsCancels:              make(map[string]context.CancelFunc),
	err := envWatcher.EnvWatchEventHandlers(ctx)
// ← пропущена } закрывающая struct literal

Причина: replace_string_in_file не нашёл точное совпадение с нужным whitespace и применил замену частично некорректно.

Исправление: второй вызов replace_string_in_file с правильным контекстом (включая соседние строки для однозначного совпадения).

Вывод компилятора:

pkg/buildermgr/envwatcher.go:117:6: syntax error: unexpected := in composite literal; possibly missing comma or }

Ошибка 2: Тест-фейк не реализует интерфейс

Что случилось: После добавления RemoveNamespace в interface ExecutorType, тест ns_watcher_test.go не компилировался:

*fakeExecutorType does not implement executortype.ExecutorType (missing method RemoveNamespace)

Исправление: добавлена заглушка в fakeExecutorType.


9. Инварианты безопасности

  1. nsCancel() идемпотентен: повторный вызов не паникует (context package гарантирует это)
  2. RemoveNamespace("") защищён early return во всех реализациях
  3. deregisterBuilderNamespace через type assertion — безопасно если интерфейс не реализован (просто пропускает)
  4. routerNamespaceRemover через type assertion в router subscriber — аналогично
  5. Goroutines informer factories останавливаются асинхронно после cancel() — это нормально, k8s client-go гарантирует graceful shutdown при отмене контекста
  6. После RemoveNamespace и до следующего AddNamespace — любые события от k8s для этого NS будут проигнорированы (informers остановлены, listers удалены)