mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-18 18:01:14 +03:00
re-apply runtime overrides to the config without re-broadcasting them (#23739)
/api/config/set and camera deletion re-parse yaml into a fresh FrigateConfig and swap it in, then re-layered the persisted runtime toggle overrides so a camera the user turned off wouldn't come back on. That re-layer ran apply_runtime_state, which replays each override through the command handlers, so every save re-published a ZMQ config update, a retained MQTT state message, and a runtime-state disk write for every camera with a stored toggle. All of it was redundant: the worker processes were never swapped and still hold the live toggle values, so only the in-process config object the API and dispatcher read was out of date. The extra traffic churned the retained MQTT topics, amplified disk writes, and co-drained enabled updates with other topics on the config socket. Add Dispatcher.reapply_runtime_state_to_config, which corrects only the swapped-in config object, mirroring the field mutations and gates of the _on_*_command handlers with no ZMQ, MQTT, or disk writes. swap_runtime_config now calls it instead of apply_runtime_state; apply_runtime_state is unchanged and still used at startup, where the workers genuinely must be told.
This commit is contained in:
parent
f1028d0c36
commit
48aaafba3c
@ -30,4 +30,6 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
for comm in app.dispatcher.comms:
|
||||
comm.config = config
|
||||
|
||||
app.dispatcher.apply_runtime_state()
|
||||
# workers still hold the live toggle values, so correct only the
|
||||
# config object here rather than re-broadcasting every override
|
||||
app.dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
@ -492,6 +492,48 @@ class Dispatcher:
|
||||
"""
|
||||
self._runtime_state.clear_camera(camera)
|
||||
|
||||
def reapply_runtime_state_to_config(self) -> None:
|
||||
"""Re-apply persisted runtime overrides to the swapped-in config object.
|
||||
|
||||
After config/set (or a camera delete) parses fresh yaml and swaps the
|
||||
config, the worker processes still hold the live toggle values and the
|
||||
overrides are already on disk, so only the in-process config object is
|
||||
out of date. Unlike apply_runtime_state (used at startup, where workers
|
||||
must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects
|
||||
the config the API and dispatcher read.
|
||||
|
||||
The field mutations and gates mirror the _on_*_command handlers; keep
|
||||
the two in sync if a tracked toggle is added or its gate changes.
|
||||
"""
|
||||
state = self._runtime_state.load()
|
||||
|
||||
for camera_name, features in state.items():
|
||||
camera = self.config.cameras.get(camera_name)
|
||||
|
||||
if camera is None:
|
||||
continue
|
||||
|
||||
for topic, value in features.items():
|
||||
if topic == "enabled":
|
||||
if value and not camera.enabled_in_config:
|
||||
continue
|
||||
camera.enabled = value
|
||||
elif topic == "detect":
|
||||
camera.detect.enabled = value
|
||||
# detection requires motion, mirror the handler coupling
|
||||
if value and not camera.motion.enabled:
|
||||
camera.motion.enabled = True
|
||||
elif topic == "snapshots":
|
||||
camera.snapshots.enabled = value
|
||||
elif topic == "recordings":
|
||||
if value and not camera.record.enabled_in_config:
|
||||
continue
|
||||
camera.record.enabled = value
|
||||
elif topic == "audio":
|
||||
if value and not camera.audio.enabled_in_config:
|
||||
continue
|
||||
camera.audio.enabled = value
|
||||
|
||||
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
||||
"""Callback for detect topic."""
|
||||
detect_settings = self.config.cameras[camera_name].detect
|
||||
|
||||
@ -118,7 +118,7 @@ class TestDeleteCameraRuntimeConfig(BaseTestHttp):
|
||||
self.assertIn("back_yard", dispatcher.config.cameras)
|
||||
|
||||
# surviving cameras' overrides are re-layered onto the new object
|
||||
dispatcher.apply_runtime_state.assert_called_once_with()
|
||||
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
|
||||
# the deleted camera's persisted overrides are pruned
|
||||
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
|
||||
|
||||
@ -143,11 +143,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
# front_door was turned off via the UI: the override is on disk, and
|
||||
# yaml still says enabled: true. Stand in for the real replay, which
|
||||
# reads dispatcher.config - the object the endpoint just swapped in.
|
||||
def fake_apply():
|
||||
def fake_reapply():
|
||||
dispatcher.config.cameras["front_door"].enabled = False
|
||||
return {"front_door": {"enabled": False}}
|
||||
|
||||
dispatcher.apply_runtime_state.side_effect = fake_apply
|
||||
dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_dispatcher(dispatcher)
|
||||
@ -168,7 +167,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
|
||||
# the swap must be repaired: the new config object the API and
|
||||
# dispatcher now share has to still show front_door as off
|
||||
dispatcher.apply_runtime_state.assert_called_once_with()
|
||||
dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
self.assertFalse(app.frigate_config.cameras["front_door"].enabled)
|
||||
self.assertIs(dispatcher.config, app.frigate_config)
|
||||
|
||||
@ -178,7 +177,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
call_names = [name for name, _, _ in dispatcher.mock_calls]
|
||||
self.assertLess(
|
||||
call_names.index("clear_runtime_state_for_yaml_keys"),
|
||||
call_names.index("apply_runtime_state"),
|
||||
call_names.index("reapply_runtime_state_to_config"),
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
@ -205,7 +204,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
dispatcher.apply_runtime_state.assert_not_called()
|
||||
dispatcher.reapply_runtime_state_to_config.assert_not_called()
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
swap_runtime_config(app, config)
|
||||
|
||||
# the swap rebuilds cameras from yaml, so overrides must be re-layered
|
||||
app.dispatcher.apply_runtime_state.assert_called_once_with()
|
||||
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
|
||||
def test_tolerates_missing_optional_collaborators(self) -> None:
|
||||
app = MagicMock()
|
||||
|
||||
@ -253,5 +253,115 @@ class TestClearPassthrough(unittest.TestCase):
|
||||
dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door")
|
||||
|
||||
|
||||
class TestReapplyRuntimeStateToConfig(unittest.TestCase):
|
||||
"""The silent re-apply corrects the config object with no side effects."""
|
||||
|
||||
def _dispatcher_with(
|
||||
self, cameras: dict[str, MagicMock], state: dict
|
||||
) -> Dispatcher:
|
||||
dispatcher = _build_dispatcher(cameras)
|
||||
dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence)
|
||||
dispatcher._runtime_state.load.return_value = state
|
||||
dispatcher.publish = MagicMock()
|
||||
return dispatcher
|
||||
|
||||
def test_mutates_every_tracked_field(self) -> None:
|
||||
cameras = {"front_door": _make_camera_mock()}
|
||||
dispatcher = self._dispatcher_with(
|
||||
cameras,
|
||||
{
|
||||
"front_door": {
|
||||
"enabled": False,
|
||||
"detect": False,
|
||||
"snapshots": False,
|
||||
"recordings": False,
|
||||
"audio": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
cam = cameras["front_door"]
|
||||
self.assertFalse(cam.enabled)
|
||||
self.assertFalse(cam.detect.enabled)
|
||||
self.assertFalse(cam.snapshots.enabled)
|
||||
self.assertFalse(cam.record.enabled)
|
||||
self.assertFalse(cam.audio.enabled)
|
||||
|
||||
def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None:
|
||||
dispatcher = self._dispatcher_with(
|
||||
{"front_door": _make_camera_mock()},
|
||||
{"front_door": {"enabled": False}},
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
dispatcher.config_updater.publish_update.assert_not_called()
|
||||
dispatcher._runtime_state.set.assert_not_called()
|
||||
dispatcher.publish.assert_not_called()
|
||||
|
||||
def test_respects_enabled_in_config_gate(self) -> None:
|
||||
# an ON override for a camera disabled in yaml must not enable it
|
||||
cameras = {
|
||||
"front_door": _make_camera_mock(enabled=False, enabled_in_config=False)
|
||||
}
|
||||
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertFalse(cameras["front_door"].enabled)
|
||||
|
||||
def test_respects_recordings_and_audio_gates(self) -> None:
|
||||
# ON overrides for recordings/audio not enabled in yaml must be ignored
|
||||
cameras = {
|
||||
"front_door": _make_camera_mock(
|
||||
record_enabled=False,
|
||||
record_enabled_in_config=False,
|
||||
audio_enabled=False,
|
||||
audio_enabled_in_config=False,
|
||||
)
|
||||
}
|
||||
dispatcher = self._dispatcher_with(
|
||||
cameras, {"front_door": {"recordings": True, "audio": True}}
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertFalse(cameras["front_door"].record.enabled)
|
||||
self.assertFalse(cameras["front_door"].audio.enabled)
|
||||
|
||||
def test_applies_on_override_when_gate_passes(self) -> None:
|
||||
# a camera off in yaml but enabled_in_config keeps its runtime-on state
|
||||
cameras = {
|
||||
"front_door": _make_camera_mock(enabled=False, enabled_in_config=True)
|
||||
}
|
||||
dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}})
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertTrue(cameras["front_door"].enabled)
|
||||
|
||||
def test_detect_on_couples_motion(self) -> None:
|
||||
cam = _make_camera_mock(detect_enabled=False)
|
||||
cam.motion.enabled = False
|
||||
dispatcher = self._dispatcher_with(
|
||||
{"front_door": cam}, {"front_door": {"detect": True}}
|
||||
)
|
||||
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
self.assertTrue(cam.detect.enabled)
|
||||
self.assertTrue(cam.motion.enabled)
|
||||
|
||||
def test_skips_camera_not_in_config(self) -> None:
|
||||
dispatcher = self._dispatcher_with(
|
||||
{"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}}
|
||||
)
|
||||
|
||||
# a stale entry for a deleted camera must be ignored, not raise
|
||||
dispatcher.reapply_runtime_state_to_config()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user