Перейти к основному содержимому

Горячая перезагрузка (watch)

:::caution API Phase 2 — не активен в v0.1 / v0.2 Реактивные обновления описаны как целевая семантика, но в текущих биндингах (dagstack-config v0.2.0, config-go v0.1.0, @dagstack/config v0.1.0) не реализованы: методы регистрации (on_change / on_section_change / reload в Python, OnChange / OnSectionChange / Reload в Go) есть в API и возвращают Subscription / *Subscription, но callback никогда не вызывается (active = False / IsActive() == false). reload() / Reload(ctx) — no-op. В TypeScript эти методы пока вообще не выставлены.

В Phase 2 появится: наблюдение через fsnotify для YamlFileSource, атомарный откат при невалидной перезагрузке и активные подписки. До этого момента страница служит целевой спецификацией (ADR-0001 §7.2) для пилотной реализации — синтаксис будет ровно таким, как описано ниже. :::

dagstack/config поддерживает реактивные обновления: компоненты приложения могут подписаться на изменения собственной секции и применить их без перезапуска процесса. Поддержка зависит от возможностей ConfigSource: YamlFileSource следит за файлами через fsnotify; EtcdSource использует gRPC-стрим; HttpSource — SSE / long polling.

Базовая подписка

from pydantic import BaseModel


class DatabaseConfig(BaseModel):
host: str
port: int = 5432
name: str
pool_size: int = 20


config = Config.load("app-config.yaml")

def on_new_db_config(new: DatabaseConfig) -> None:
print(f"Database reconfigured: pool_size={new.pool_size}")
apply_pool(new)

sub = config.on_section_change("database", DatabaseConfig, on_new_db_config)

# ... в какой-то момент остановить подписку ...
sub.unsubscribe()

Объект Subscription

onSectionChange возвращает объект Subscription со следующими полями:

unsubscribe(): void # остановить подписку, идемпотентно
active: boolean # получает ли callback события
inactive_reason: string | null # если active=false, причина
path: string # отслеживаемая секция

После unsubscribe() callback не вызывается даже для уже идущих циклов перезагрузки (см. раздел про атомарность ниже).

Атомарный откат при невалидной конфигурации

Когда любой источник сигнализирует об изменении:

  1. Загрузчик собирает новый ConfigTree (с учётом всех слоёв).
  2. Применяется интерполяция env.
  3. Для каждой подписанной секции новое значение валидируется по схеме.
  4. Если хотя бы одна валидация упала, вся перезагрузка откатывается. Никто из подписчиков не уведомляется. В диагностический лог пишется предупреждение.
  5. Если все валидации прошли, подписчики получают обратные вызовы параллельно (fire-and-forget).

Эта атомарность на уровне всего дерева не даёт частично применить невалидную конфигурацию. Пример:

app-config.yaml (валидный)
database:
pool_size: 20
cache:
ttl_min: 15
app-config.yaml (после правки — cache.ttl_min сломан)
database:
pool_size: 50 # валидно
cache:
ttl_min: "fifteen" # невалидно — ожидается int

Поведение:

  • Подписчик database не получит новый pool_size=50.
  • Подписчик cache тоже не получит нового значения.
  • В диагностический лог записывается: reload rejected — cache.ttl_min: validation_failed (expected int, got string).
  • Приложение продолжает работать со старыми значениями обеих секций.

Контракт защищает от ситуации, когда один компонент применил новый конфиг, а другой — нет, и система оказалась в несогласованном состоянии.

active=false — когда источник не умеет следить

Если ни один из активных источников не поддерживает наблюдение за изменениями, подписка регистрируется, но никогда не получает событий:

sub = config.on_section_change("database", DatabaseConfig, callback)
print(sub.active) # False
print(sub.inactive_reason) # "subscription_without_watch"

Код компонента работает одинаково в обоих сценариях — при active=false callback просто не срабатывает. На момент подписки в лог однократно пишется диагностическое предупреждение subscription_without_watch, чтобы оператор знал: горячая перезагрузка отключена.

Стратегии применения изменений

Стратегия 1 — целиком заменить клиент / пул:

def on_new_db_config(new: DatabaseConfig) -> None:
old_pool = self._pool
self._pool = create_pool(new) # атомарная подмена
asyncio.create_task(gracefully_close(old_pool)) # закрыть старый пул в фоне

Стратегия 2 — обновление на месте:

def on_new_db_config(new: DatabaseConfig) -> None:
self._pool.resize(new.pool_size)
# host/port на лету не меняются — нужна пересборка.

Стратегия 3 — отложенное применение:

self._pending_db = None

def on_new_db_config(new: DatabaseConfig) -> None:
self._pending_db = new

# На входе каждого запроса:
if self._pending_db:
self.apply_db_config(self._pending_db)
self._pending_db = None

Выбор зависит от типа клиента и характера изменений. on_section_change / onSectionChange / OnSectionChange вызывается из внутреннего контекста загрузчика (точное планирование зависит от реализации: Python и TypeScript используют async-таску, Go — горутину), поэтому callback должен быть быстрым и потокобезопасным.

Ограничения

  • Наблюдение работает на уровне источника целиком, а не конкретной секции. Загрузчик подписывается на источник, слышит «что-то изменилось» и пересобирает всё дерево.
  • Обратные вызовы — fire-and-forget; ошибка внутри обратного вызова логируется и не прокидывается дальше; остальные подписчики уведомляются штатно.
  • Гарантий по порядку между подписчиками разных секций нет.

Когда watch не работает

ConfigSource может не поддерживать наблюдение по техническим причинам:

  • InMemorySource (Python/TS) / DictSource (Go) — лежит в памяти, заполняется из кода, в рантайме не меняется.
  • JsonFileSource — наблюдение не реализовано в v0.1 (fsnotify в плане Phase 2).
  • Кастомный источник без метода watch().

В этих случаях подписка всё равно регистрируется, но active=false. Приложение должно корректно работать без горячей перезагрузки (например, требуя перезапуска при смене конфигурации в production).

См. также