Skip to main content

Hot reload (watch)

:::caution Phase 2 API — not active in v0.1 / v0.2 Reactive updates are described as the target semantics, but in the current bindings (dagstack-config v0.2.0, config-go v0.1.0, @dagstack/config v0.1.0) they are not implemented: the registration methods (on_change / on_section_change / reload in Python, OnChange / OnSectionChange / Reload in Go) are present in the API and return Subscription / *Subscription, but the callback is never invoked (active = False / IsActive() == false). reload() / Reload(ctx) are no-ops. TypeScript does not yet expose these methods at all.

In Phase 2 the following will land: fsnotify-based watching for YamlFileSource, atomic rollback on an invalid reload, and active subscriptions. Until then, this page serves as the target specification (ADR-0001 §7.2) for the pilot implementation — the syntax will look exactly as described below. :::

dagstack/config supports reactive updates: components in your application can subscribe to changes for their own section and apply them without restarting the process. Support depends on the capabilities of the ConfigSource: YamlFileSource watches files via fsnotify; EtcdSource uses a gRPC stream; HttpSource uses SSE / long polling.

A basic subscription

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)

# ... at some point, stop the subscription ...
sub.unsubscribe()

The Subscription object

onSectionChange returns a Subscription object with these fields:

unsubscribe(): void # stop the subscription, idempotent
active: boolean # whether the callback is receiving events
inactive_reason: string | null # if active=false, the reason
path: string # the section being watched

After unsubscribe(), the callback is not invoked even for reload cycles already in flight (see the section on atomicity below).

Atomic rollback on invalid configuration

When any source signals a change:

  1. The loader assembles a new ConfigTree (taking all layers into account).
  2. Env interpolation is applied.
  3. For every subscribed section, the new value is validated against its schema.
  4. If any validation fails, the entire reload is rolled back. No subscriber is notified. A warning is written to the diagnostic log.
  5. If every validation passes, subscribers receive their callbacks in parallel (fire-and-forget).

This tree-wide atomicity prevents partial application of an invalid configuration. Example:

app-config.yaml (valid)
database:
pool_size: 20
cache:
ttl_min: 15
app-config.yaml (after edit — cache.ttl_min is broken)
database:
pool_size: 50 # valid
cache:
ttl_min: "fifteen" # invalid — int expected

Behavior:

  • The database subscriber does not receive the new pool_size=50.
  • The cache subscriber does not receive a new value either.
  • The diagnostic log records: reload rejected — cache.ttl_min: validation_failed (expected int, got string).
  • The application keeps running with the old values for both sections.

The contract guards against the situation where one component applies a new config and another does not, leaving the system in an inconsistent state.

active=false — when the source cannot watch

If none of the active sources support watching, the subscription is registered but never receives events:

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

The component code works the same in either scenario — when active=false, the callback simply never fires. A diagnostic warning subscription_without_watch is logged once at subscription time so the operator knows that hot reload is disabled.

Strategies for applying changes

Strategy 1 — replace the client / pool wholesale:

def on_new_db_config(new: DatabaseConfig) -> None:
old_pool = self._pool
self._pool = create_pool(new) # atomic swap
asyncio.create_task(gracefully_close(old_pool)) # close the old pool in the background

Strategy 2 — inline update:

def on_new_db_config(new: DatabaseConfig) -> None:
self._pool.resize(new.pool_size)
# host/port can't be changed live — they require recreation.

Strategy 3 — deferred:

self._pending_db = None

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

# At the start of every request:
if self._pending_db:
self.apply_db_config(self._pending_db)
self._pending_db = None

The choice depends on the kind of client and the nature of the change. on_section_change / onSectionChange / OnSectionChange is invoked from the loader's internal context (the exact scheduling depends on the implementation: Python and TypeScript use an async task, Go uses a goroutine), so the callback must be fast and concurrency-safe.

Limitations

  • Watching operates at the level of an entire source, not a specific section. The loader subscribes to a source, hears "something changed", and rebuilds the whole tree.
  • Callbacks are fire-and-forget — an error inside a callback is logged and not propagated; other subscribers still receive their notifications.
  • There is no ordering guarantee between subscribers of different sections.

When watch does not work

A ConfigSource may not support watching for technical reasons:

  • InMemorySource (Python/TS) / DictSource (Go) — held in memory, populated from code, never changes at runtime.
  • JsonFileSource — watch is not implemented in v0.1 (planned for fsnotify in Phase 2).
  • A custom source without a watch() method.

In these cases the subscription is still registered, but active=false. The application must keep working correctly without hot reload (for example, requiring a restart on configuration changes in production).

See also