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

34 KiB
Raw Blame History

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():
    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 получит это событие через AddFuncsyncTriggers(). Это нормально.
  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 (~3–5 горутин). ~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 без изменения публичного интерфейса.

// В 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)

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.