feat: hot-reload config.toml via polling - #299
Closed
taylor-osler-sentry wants to merge 9 commits into
Closed
Conversation
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
marked this pull request as ready for review
July 28, 2026 21:15
watchfiles is no longer a dependency, so uv.lock matches main again.
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.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ 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.
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.
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).
| # 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 | ||
|
|
There was a problem hiding this comment.
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.pyline 633 definesCONFIG_WATCH_POLL_SECONDSfrom the environment viafloat()without clamping or validation.config_reload._poll_loopat 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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 readsconfig.toml, comparing a sha256 of its contents. Each process holds its own in-memorysettings, 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/notifywatcher just logsrust notify timeoutand 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-runslogging.config.dictConfig, re-inits Datadog (not Sentry — see below), and refreshes the DB connection handler cache so newDATABASEStake effect.CONFIGsetting exposing the activeConfigFile(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_reload.py—start_config_watcher()(idempotent, per-process), the poll loop (_poll_loop/_hash_file), andreload_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) viaos.register_at_fork, sincefork()doesn't clone the watcher thread.config_hooks.py—on_config_reload(old_config, new_config)extension point.incidents/apps.py— start the watcher fromready()when enabled.test_config_reload.py— tests forapply_config,reload_config, and the poll/hash detection loop.Scope / risks
Reload covers everything derived from config, including
SECRET_KEY/SALT_KEY/DATABASES:SECRET_KEYinvalidates active sessions/signed cookies.SALT_KEYbreaks decryption of existing encrypted DB fields unless the old key stays insalt_keys.DATABASESchanges 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.initon a live process is unsupported, sosentry_dsnchanges 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.