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.
Базовая подписка
- Python
- TypeScript
- Go
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()
import { z } from "zod";
import { Config } from "@dagstack/config";
const DatabaseConfig = z.object({
host: z.string(),
port: z.number().int().default(5432),
name: z.string(),
pool_size: z.number().int().default(20),
});
type DatabaseConfig = z.infer<typeof DatabaseConfig>;
const config = await Config.load("app-config.yaml");
const sub = config.onSectionChange("database", DatabaseConfig, (newConfig: DatabaseConfig) => {
console.log(`Database reconfigured: pool_size=${newConfig.pool_size}`);
applyPool(newConfig);
});
// ...
sub.unsubscribe();
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Name string `yaml:"name"`
PoolSize int `yaml:"pool_size"`
}
cfg, _ := config.Load(context.Background(), "app-config.yaml")
sub, _ := cfg.OnSectionChange("database", &DatabaseConfig{}, func(new any) {
c := new.(*DatabaseConfig)
log.Printf("Database reconfigured: pool_size=%d", c.PoolSize)
applyPool(c)
})
defer sub.Unsubscribe()
Subscription-объект
onSectionChange возвращает объект Subscription с полями:
unsubscribe(): void # остановить подписку, идемпотентно
active: boolean # получает ли callback события
inactive_reason: string | null # если active=false, причина
path: string # какую секцию отслеживает
После unsubscribe() callback не вызывается даже для уже запущенных циклов перезагрузки (см. раздел про атомарность).
Атомарный откат при невалидной конфигурации
Когда любой источник сигнализирует об изменении:
- Загрузчик собирает новое ConfigTree (учитывая все слои).
- Применяет env-интерполяцию.
- Для каждой подписанной секции валидирует новое значение против её schema.
- Если любая из валидаций провалилась — вся операция перезагрузки откатывается. Ни один подписчик не получает уведомления. Пишется warning в диагностический лог.
- Если все валидации прошли — подписчики параллельно получают вызовы callback (fire-and-forget).
Эта атомарность на уровне всего дерева предотвращает частичное применение невалидного конфига. Пример:
database:
pool_size: 20
cache:
ttl_min: 15
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, подписка регистрируется, но не получает событий:
- Python
- TypeScript
- Go
sub = config.on_section_change("database", DatabaseConfig, callback)
print(sub.active) # False
print(sub.inactive_reason) # "subscription_without_watch"
const sub = config.onSectionChange("database", DatabaseConfig, callback);
console.log(sub.active); // false
console.log(sub.inactive_reason); // "subscription_without_watch"
sub, _ := cfg.OnSectionChange("database", &DatabaseConfig{}, callback)
fmt.Println(sub.Active()) // false
fmt.Println(sub.InactiveReason()) // "subscription_without_watch"
Код компонента работает одинаково в обоих сценариях — просто при active=false callback никогда не вызывается. Диагностический warning subscription_without_watch логируется один раз при подписке, чтобы оператор знал, что hot-reload отключён.
Стратегии применения изменений
Стратегия 1 — полная замена клиента/пула:
- Python
- TypeScript
- Go
def on_new_db_config(new: DatabaseConfig) -> None:
old_pool = self._pool
self._pool = create_pool(new) # атомарная подмена
asyncio.create_task(gracefully_close(old_pool)) # фоновое завершение старого
function onNewDbConfig(next: DatabaseConfig): void {
const oldPool = this.pool;
this.pool = createPool(next); // атомарная подмена
void gracefullyClose(oldPool); // фоновое завершение старого
}
func (s *Service) onNewDbConfig(new any) {
c := new.(*DatabaseConfig)
old := s.pool.Load()
s.pool.Store(createPool(c)) // атомарная подмена через atomic.Pointer
go gracefullyClose(old) // фоновое завершение старого
}
Стратегия 2 — inline update:
- Python
- TypeScript
- Go
def on_new_db_config(new: DatabaseConfig) -> None:
self._pool.resize(new.pool_size)
# host/port не меняем на лету — требует recreate.
function onNewDbConfig(next: DatabaseConfig): void {
this.pool.resize(next.pool_size);
// host/port не меняем на лету — требует recreate.
}
func (s *Service) onNewDbConfig(new any) {
c := new.(*DatabaseConfig)
s.pool.Resize(c.PoolSize)
// host/port не меняем на лету — требует recreate.
}
Стратегия 3 — deferred:
- Python
- TypeScript
- Go
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
let pendingDb: DatabaseConfig | null = null;
function onNewDbConfig(next: DatabaseConfig): void {
pendingDb = next;
}
// В начале каждого запроса:
if (pendingDb !== null) {
applyDbConfig(pendingDb);
pendingDb = null;
}
var pendingDB atomic.Pointer[DatabaseConfig]
func onNewDbConfig(new any) {
c := new.(*DatabaseConfig)
pendingDB.Store(c)
}
// В начале каждого запроса:
if c := pendingDB.Swap(nil); c != nil {
applyDBConfig(c)
}
Выбор зависит от типа клиента и характера изменения. 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).
См. также
- Источники (ConfigSource) — какие источники поддерживают watch.
- ADR-0001 §7 Reactive subscriptions — нормативный контракт subscriptions.
- Руководство: Тестирование — как мокать и проверять подписки.