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

Hot-reload (watch)

:::caution Phase 2 API — не активен в 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-watch для YamlFileSource, атомарный откат при невалидной перезагрузке, активные подписки. До тех пор страница служит целевой спецификацией (ADR-0001 §7.2) для пилотной реализации — синтаксис будет таким, как описано ниже. :::

dagstack/config поддерживает реактивные обновления — компоненты приложения могут подписаться на изменения своей секции и применить их без перезапуска процесса. Поддержка зависит от возможностей ConfigSource: YamlFileSource отслеживает изменения через fsnotify; EtcdSource — через gRPC stream; 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. Для каждой подписанной секции валидирует новое значение против её schema.
  4. Если любая из валидаций провалилась — вся операция перезагрузки откатывается. Ни один подписчик не получает уведомления. Пишется warning в диагностический лог.
  5. Если все валидации прошли — подписчики параллельно получают вызовы callback (fire-and-forget).

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

app-config.yaml (валидное)
database:
pool_size: 20
cache:
ttl_min: 15
app-config.yaml (после edit — 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 — когда источник не умеет отслеживать изменения

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

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

Код компонента работает одинаково в обоих сценариях — просто при active=false callback никогда не вызывается. Диагностический warning subscription_without_watch логируется один раз при подписке, чтобы оператор знал, что hot-reload отключён.

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

Стратегия 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 — inline update:

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

Стратегия 3 — deferred:

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-task, Go — goroutine), поэтому callback должен быть быстрым и безопасным в многопоточной / асинхронной среде.

Ограничения

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

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

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

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

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

См. также