mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-19 02:11:15 +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:
|
for comm in app.dispatcher.comms:
|
||||||
comm.config = config
|
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)
|
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:
|
def _on_detect_command(self, camera_name: str, payload: str) -> None:
|
||||||
"""Callback for detect topic."""
|
"""Callback for detect topic."""
|
||||||
detect_settings = self.config.cameras[camera_name].detect
|
detect_settings = self.config.cameras[camera_name].detect
|
||||||
|
|||||||
@ -118,7 +118,7 @@ class TestDeleteCameraRuntimeConfig(BaseTestHttp):
|
|||||||
self.assertIn("back_yard", dispatcher.config.cameras)
|
self.assertIn("back_yard", dispatcher.config.cameras)
|
||||||
|
|
||||||
# surviving cameras' overrides are re-layered onto the new object
|
# 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
|
# the deleted camera's persisted overrides are pruned
|
||||||
dispatcher.clear_runtime_state_for_camera.assert_called_once_with(
|
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
|
# 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
|
# yaml still says enabled: true. Stand in for the real replay, which
|
||||||
# reads dispatcher.config - the object the endpoint just swapped in.
|
# reads dispatcher.config - the object the endpoint just swapped in.
|
||||||
def fake_apply():
|
def fake_reapply():
|
||||||
dispatcher.config.cameras["front_door"].enabled = False
|
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:
|
try:
|
||||||
app, _ = self._create_app_with_dispatcher(dispatcher)
|
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
|
# the swap must be repaired: the new config object the API and
|
||||||
# dispatcher now share has to still show front_door as off
|
# 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.assertFalse(app.frigate_config.cameras["front_door"].enabled)
|
||||||
self.assertIs(dispatcher.config, app.frigate_config)
|
self.assertIs(dispatcher.config, app.frigate_config)
|
||||||
|
|
||||||
@ -178,7 +177,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
call_names = [name for name, _, _ in dispatcher.mock_calls]
|
call_names = [name for name, _, _ in dispatcher.mock_calls]
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
call_names.index("clear_runtime_state_for_yaml_keys"),
|
call_names.index("clear_runtime_state_for_yaml_keys"),
|
||||||
call_names.index("apply_runtime_state"),
|
call_names.index("reapply_runtime_state_to_config"),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
@ -205,7 +204,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
dispatcher.apply_runtime_state.assert_not_called()
|
dispatcher.reapply_runtime_state_to_config.assert_not_called()
|
||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
|||||||
swap_runtime_config(app, config)
|
swap_runtime_config(app, config)
|
||||||
|
|
||||||
# the swap rebuilds cameras from yaml, so overrides must be re-layered
|
# 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:
|
def test_tolerates_missing_optional_collaborators(self) -> None:
|
||||||
app = MagicMock()
|
app = MagicMock()
|
||||||
|
|||||||
@ -253,5 +253,115 @@ class TestClearPassthrough(unittest.TestCase):
|
|||||||
dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user