Skip to content

feat: hot-reload config.toml via polling - #299

Closed
taylor-osler-sentry wants to merge 9 commits into
mainfrom
feat/config-hot-reload
Closed

feat: hot-reload config.toml via polling#299
taylor-osler-sentry wants to merge 9 commits into
mainfrom
feat/config-hot-reload

Conversation

@taylor-osler-sentry

@taylor-osler-sentry taylor-osler-sentry commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds hot-reloading of config.toml: when the file changes on disk, config-derived Django settings are re-applied in place without a restart.

Mechanism: a daemon thread runs inside every Django process (started from IncidentsConfig.ready) and periodically reads config.toml, comparing a sha256 of its contents. Each process holds its own in-memory settings, so the watcher must run per-process to propagate changes to web workers, the Slack bot, and q-cluster workers. A standalone watcher process could only mutate its own settings.

Why polling, not inotify: Cloud Run runs under the gVisor sandbox, which does not deliver inotify filesystem events (an event-based watchfiles/notify watcher just logs rust notify timeout and never fires). Hashing file contents (vs comparing mtime) also transparently handles the symlink swap that Kubernetes uses when updating a mounted ConfigMap.

Changes

  • settings.py — refactor so all config-derived settings come from a single _build_config_settings(config) (one source of truth for import-time and reload). Add:
    • apply_config(config) — reload entrypoint. Rebuilds + validates first (a bad config never partially applies), rebinds each setting wholesale onto the live settings object (GIL-atomic, no torn reads), re-runs logging.config.dictConfig, re-inits Datadog (not Sentry — see below), and refreshes the DB connection handler cache so new DATABASES take effect.
    • CONFIG setting exposing the active ConfigFile (used to pass old to new to the hook).
    • CONFIG_WATCH_ENABLED — gates the watcher off for one-shot management commands and pytest.
    • CONFIG_WATCH_POLL_SECONDS — poll interval (default 15s, env-overridable).
    • Config-derived names kept as annotated assignments so django-stubs/mypy still see every setting's type.
  • config_reload.pystart_config_watcher() (idempotent, per-process), the poll loop (_poll_loop / _hash_file), and reload_config(). A persistently-invalid file isn't retried every tick; it reloads on the next edit. Load/apply/hook failures are logged, not fatal. Re-arms the watcher in forked children (django-q workers, preloaded gunicorn workers) via os.register_at_fork, since fork() doesn't clone the watcher thread.
  • config_hooks.pyon_config_reload(old_config, new_config) extension point.
  • incidents/apps.py — start the watcher from ready() when enabled.
  • test_config_reload.py — tests for apply_config, reload_config, and the poll/hash detection loop.

Scope / risks

Reload covers everything derived from config, including SECRET_KEY / SALT_KEY / DATABASES:

  • Rotating SECRET_KEY invalidates active sessions/signed cookies.
  • Rotating SALT_KEY breaks decryption of existing encrypted DB fields unless the old key stays in salt_keys.
  • DATABASES changes take effect on the next connection: the reload refreshes the connection handler's cached settings, and each worker retires its own connection via the normal end-of-request cycle. In-flight requests finish on their existing connection.

Datadog is re-initialized on each reload; Sentry is not — re-running sentry_sdk.init on a live process is unsupported, so sentry_dsn changes require a restart. Changes are detected within one poll interval (default 15s) rather than instantly.

Testing

  • mypy src, ruff check, ruff format, full pre-commit: pass.
  • Full suite: 1240 tests pass (requires local Postgres).

Watch config.toml with a per-process watchfiles daemon thread (started
from IncidentsConfig.ready) and re-apply config-derived Django settings
in place when it changes. Each Django process holds its own in-memory
settings, so the watcher must run inside every process to propagate.

Refactor settings.py so all config-derived values are produced by a
single _build_config_settings function shared by import-time assignment
and the reload path (apply_config). apply_config rebuilds and validates
before mutating, rebinds each setting wholesale, re-runs logging config,
re-inits Sentry/Datadog, and closes DB connections. Add on_config_reload
hook for extra logic on change.
Cloud Run runs under the gVisor sandbox, which does not deliver inotify
events, so the watchfiles/notify backend times out. Replace the
event-based watcher with a daemon thread that periodically reads
config.toml and compares a sha256 of its contents, reloading on change.
Content hashing (vs mtime) also handles the symlink swap Kubernetes uses
for mounted ConfigMaps. Drop the watchfiles dependency and add
CONFIG_WATCH_POLL_SECONDS (default 5s, env-overridable).
@taylor-osler-sentry taylor-osler-sentry changed the title feat: hot-reload config.toml via watchfiles feat: hot-reload config.toml via polling Jul 28, 2026
@taylor-osler-sentry
taylor-osler-sentry marked this pull request as ready for review July 28, 2026 21:15
@taylor-osler-sentry
taylor-osler-sentry requested a review from a team as a code owner July 28, 2026 21:15
watchfiles is no longer a dependency, so uv.lock matches main again.
Comment thread src/firetower/settings.py Outdated
Comment thread src/firetower/settings.py Outdated
connections.close_all() did not make DATABASES changes take effect on
reload: ConnectionHandler stores connections in thread-local storage
(so close_all() from the watcher thread closes nothing in worker
threads) and caches settings.DATABASES via @cached_property (so new
connections were still built from the stale config). On a credential
rotation this caused auth failures.

Invalidate the handler's cached settings (the reset Django uses for
CACHES) so new connections use the new config; each worker retires its
own connection through the normal end-of-request cycle. We do not
force-close other threads' connections, which is not thread-safe.
Comment thread src/firetower/config_reload.py
Comment thread src/firetower/incidents/apps.py
Comment thread src/firetower/settings.py
Re-running sentry_sdk.init on an already-initialized process is
unsupported and can leave the SDK in a bad state. Split Sentry into a
boot-only _init_sentry, called once at settings import; the reload path
runs only _apply_config_side_effects (Datadog). sentry_dsn changes now
require a restart.
Comment thread src/firetower/config_reload.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1ea3613. Configure here.

Comment thread src/firetower/settings.py Outdated
Comment thread src/firetower/settings.py
django-q2 forks its worker processes via multiprocessing (fork start
method), and gunicorn --preload forks workers too. fork() clones only
the calling thread, so children never inherit the watcher thread, yet
the copied _started flag stays True and AppConfig.ready is not re-run --
so q-cluster workers never watch and keep stale settings, defeating the
per-process reload design.

Register an os.register_at_fork(after_in_child=...) handler when the
watcher first starts. In the child it replaces the sync primitives (in
case the parent held them at fork time) and starts a fresh watcher.
IAPAuthenticationMiddleware is built once per process and cached the
validator (and IAPTokenValidator cached IAP_AUDIENCE) at __init__, so a
config reload of IAP_ENABLED/IAP_AUDIENCE was ignored: a changed audience
kept validating against the stale value, and enabling IAP after boot hit
the validator-is-None path and returned AnonymousUser for everyone.

Read IAP_AUDIENCE live via a property on the validator, and always
construct the (now stateless) validator so enabling IAP after boot works
without a restart. Removes the now-dead validator-is-None branch.
Comment thread src/firetower/settings.py Outdated
Previously apply_config rebound all settings (incl. DATABASES) and only
then ran dictConfig() and the connection-handler reset. A bad log level
makes dictConfig() raise after DATABASES was already updated but before
the handler cache was swapped, leaving settings.DATABASES new while new
connections still used the old cached config. reload_config swallows the
error and _poll_loop advances last_hash, so the split state persisted
until the next file edit.

Restructure into two phases: (1) build and validate everything that can
raise -- settings values, normalized DATABASES, log level (now validated
in _build_logging), and Datadog re-init -- before mutating any global
state; (2) commit with steps that cannot raise, keeping the setattr loop
and the connection-handler swap adjacent (dictConfig strictly last) so
settings and connections always move together. A bad config now aborts
with zero mutation, so the process stays fully on its previous config.
django-fernet-encrypted-fields derives its Fernet keys from SALT_KEY/
SECRET_KEY and caches them (keys/f @cached_property) on process-lived
field instances. So although apply_config rebinds settings.SALT_KEY on
reload, encryption/decryption kept using the old derived keys until a
restart. This silently defeated salt rotation: adding a key to the list
looked applied (settings reported both) while MultiFernet still used only
the old key, then 'started working' after the next restart.
LinearOAuthToken.access_token is the only affected field today.

When SALT_KEY or SECRET_KEY changes, drop the cached keys/f on every
EncryptedFieldMixin field so they re-derive from the new settings on next
use (lazy, deterministic -> safe mid-request).
@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

RELENG-946

# one-shot management commands and tests don't spawn watcher threads.
if settings.CONFIG_WATCH_ENABLED:
from firetower.config_reload import start_config_watcher # noqa: PLC0415

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config watcher polling interval lacks lower bound

CONFIG_WATCH_POLL_SECONDS lacks a positive lower bound, so a misconfigured value of 0 or negative causes _poll_loop to spin-wait and max out CPU.

Evidence
  • settings.py line 633 defines CONFIG_WATCH_POLL_SECONDS from the environment via float() without clamping or validation.
  • config_reload._poll_loop at line 91 passes the interval directly to _stop_event.wait(interval); Event.wait(0) (or negative) returns immediately, creating a busy loop.
  • start_config_watcher() spawns the thread that executes this loop inside every Django process.
  • Default is 15s, but a bad env value silently exhausts CPU in every process.
Also found at 1 additional location
  • src/firetower/settings.py:633

Identified by Warden · find-bugs · T9Q-X8C

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant