Skip to content

Commit cd6f02a

Browse files
authored
[fix] Background task update_config launched multiple times #1128
Fixes #1128
1 parent 84fa8fc commit cd6f02a

File tree

5 files changed

+354
-16
lines changed

5 files changed

+354
-16
lines changed

openwisp_controller/config/base/config.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections import defaultdict
55

66
from cache_memoize import cache_memoize
7+
from django.core.cache import cache
78
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
89
from django.db import models, transaction
910
from django.utils.translation import gettext_lazy as _
@@ -110,6 +111,7 @@ class AbstractConfig(ChecksumCacheMixin, BaseConfig):
110111
)
111112

112113
_CHECKSUM_CACHE_TIMEOUT = ChecksumCacheMixin._CHECKSUM_CACHE_TIMEOUT
114+
_CONFIG_MODIFIED_TIMEOUT = 3
113115
_config_context_functions = list()
114116
_old_backend = None
115117

@@ -124,9 +126,11 @@ def __init__(self, *args, **kwargs):
124126
self._just_created = False
125127
self._initial_status = self.status
126128
self._send_config_modified_after_save = False
129+
self._config_modified_action = "config_changed"
127130
self._send_config_deactivated = False
128131
self._send_config_deactivating = False
129132
self._send_config_status_changed = False
133+
self._is_enforcing_required_templates = False
130134

131135
def __str__(self):
132136
if self._has_device():
@@ -301,17 +305,26 @@ def templates_changed(cls, action, instance, **kwargs):
301305
# execute only after a config has been saved or deleted
302306
if action not in ["post_add", "post_remove"] or instance._state.adding:
303307
return
308+
if instance._is_enforcing_required_templates:
309+
# The required templates are enforced on "post_clear" action and
310+
# they are added back using Config.templates.add(). This sends a
311+
# m2m_changed signal with the "post_add" action.
312+
# At this stage, all templates have not yet been re-added,
313+
# so the checksum cannot be accurately evaluated.
314+
# Defer checksum validation until a subsequent post_add or
315+
# post_remove signal is received.
316+
instance._is_enforcing_required_templates = False
317+
return
304318
# use atomic to ensure any code bound to
305319
# be executed via transaction.on_commit
306320
# is executed after the whole block
307321
with transaction.atomic():
308322
# do not send config modified signal if
309323
# config instance has just been created
310324
if not instance._just_created:
311-
# sends only config modified signal
312-
instance._send_config_modified_signal(action="m2m_templates_changed")
325+
instance._config_modified_action = "m2m_templates_changed"
313326
instance.update_status_if_checksum_changed(
314-
send_config_modified_signal=False
327+
send_config_modified_signal=not instance._just_created
315328
)
316329

317330
@classmethod
@@ -464,6 +477,7 @@ def enforce_required_templates(
464477
)
465478
)
466479
if required_templates.exists():
480+
instance._is_enforcing_required_templates = True
467481
instance.templates.add(
468482
*required_templates.order_by("name").values_list("pk", flat=True)
469483
)
@@ -587,7 +601,7 @@ def save(self, *args, **kwargs):
587601
self._old_backend = None
588602
# emit signals if config is modified and/or if status is changing
589603
if not created and self._send_config_modified_after_save:
590-
self._send_config_modified_signal(action="config_changed")
604+
self._send_config_modified_signal()
591605
self._send_config_modified_after_save = False
592606
if self._send_config_status_changed:
593607
self._send_config_status_changed_signal()
@@ -648,6 +662,14 @@ def update_status_if_checksum_changed(
648662
self._update_checksum_db(new_checksum=self.checksum_db)
649663
if send_config_modified_signal:
650664
self._send_config_modified_after_save = True
665+
if save:
666+
# When this method is triggered by changes to Config.templates,
667+
# those changes are applied through the related manager rather
668+
# than via Config.save(). As a result, the model's save()
669+
# method (and thus the automatic "config modified" signal)
670+
# is never invoked. To ensure the signal is still emitted,
671+
# we send it explicitly here.
672+
self._send_config_modified_signal()
651673
self.invalidate_checksum_cache()
652674
return checksum_changed
653675

@@ -678,11 +700,38 @@ def _update_checksum_db(self, new_checksum=None):
678700
new_checksum = new_checksum or self.checksum
679701
self._meta.model.objects.filter(pk=self.pk).update(checksum_db=new_checksum)
680702

681-
def _send_config_modified_signal(self, action):
703+
@property
704+
def _config_modified_timeout_cache_key(self):
705+
return f"config_modified_timeout_{self.pk}"
706+
707+
def _set_config_modified_timeout_cache(self):
708+
cache.set(
709+
self._config_modified_timeout_cache_key,
710+
True,
711+
timeout=self._CONFIG_MODIFIED_TIMEOUT,
712+
)
713+
714+
def _delete_config_modified_timeout_cache(self):
715+
cache.delete(self._config_modified_timeout_cache_key)
716+
717+
def _send_config_modified_signal(self, action=None):
682718
"""
683719
Emits ``config_modified`` signal.
684-
Called also by Template when templates of a device are modified
720+
721+
A short-lived cache key prevents emitting duplicate signals inside the
722+
same change window; if that key exists the method returns early without
723+
emitting the signal again.
724+
725+
Side effects
726+
------------
727+
- Emits the ``config_modified`` Django signal with contextual data.
728+
- Resets ``_config_modified_action`` back to ``"config_changed"`` so
729+
subsequent calls without an explicit action revert to the default.
730+
- Sets the debouncing cache key to avoid duplicate emissions.
685731
"""
732+
if cache.get(self._config_modified_timeout_cache_key):
733+
return
734+
action = action or self._config_modified_action
686735
assert action in [
687736
"config_changed",
688737
"related_template_changed",
@@ -697,6 +746,12 @@ def _send_config_modified_signal(self, action):
697746
config=self,
698747
device=self.device,
699748
)
749+
cache.set(
750+
self._config_modified_timeout_cache_key,
751+
True,
752+
timeout=self._CONFIG_MODIFIED_TIMEOUT,
753+
)
754+
self._config_modified_action = "config_changed"
700755

701756
def _send_config_deactivating_signal(self):
702757
"""

openwisp_controller/config/tests/test_admin.py

Lines changed: 189 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,47 @@
5454
Group = load_model("openwisp_users", "Group")
5555

5656

57+
class TestDeviceAdminMixin:
58+
_device_params = {
59+
"name": "test-device",
60+
"hardware_id": "1234",
61+
"mac_address": CreateConfigTemplateMixin.TEST_MAC_ADDRESS,
62+
"key": CreateConfigTemplateMixin.TEST_KEY,
63+
"model": "",
64+
"os": "",
65+
"notes": "",
66+
"config-0-id": "",
67+
"config-0-device": "",
68+
"config-0-backend": "netjsonconfig.OpenWrt",
69+
"config-0-templates": "",
70+
"config-0-config": json.dumps({}),
71+
"config-0-context": "",
72+
"config-TOTAL_FORMS": 1,
73+
"config-INITIAL_FORMS": 0,
74+
"config-MIN_NUM_FORMS": 0,
75+
"config-MAX_NUM_FORMS": 1,
76+
# openwisp_controller.connection
77+
"deviceconnection_set-TOTAL_FORMS": 0,
78+
"deviceconnection_set-INITIAL_FORMS": 0,
79+
"deviceconnection_set-MIN_NUM_FORMS": 0,
80+
"deviceconnection_set-MAX_NUM_FORMS": 1000,
81+
"command_set-TOTAL_FORMS": 0,
82+
"command_set-INITIAL_FORMS": 0,
83+
"command_set-MIN_NUM_FORMS": 0,
84+
"command_set-MAX_NUM_FORMS": 1000,
85+
}
86+
# WARNING - WATCHOUT
87+
# this class attribute is changed dynamically
88+
# by other apps which add inlines to DeviceAdmin
89+
_additional_params = {}
90+
91+
def _get_device_params(self, org):
92+
p = self._device_params.copy()
93+
p.update(self._additional_params)
94+
p["organization"] = org.pk
95+
return p
96+
97+
5798
class TestImportExportMixin:
5899
"""
59100
Reused in OpenWISP Monitoring
@@ -237,6 +278,7 @@ class TestAdmin(
237278
CreateDeviceGroupMixin,
238279
CreateConfigTemplateMixin,
239280
TestVpnX509Mixin,
281+
TestDeviceAdminMixin,
240282
TestAdminMixin,
241283
TestCase,
242284
):
@@ -278,10 +320,6 @@ class TestAdmin(
278320
"command_set-MIN_NUM_FORMS": 0,
279321
"command_set-MAX_NUM_FORMS": 1000,
280322
}
281-
# WARNING - WATCHOUT
282-
# this class attribute is changed dynamically
283-
# by other apps which add inlines to DeviceAdmin
284-
_additional_params = {}
285323

286324
def setUp(self):
287325
self.client.force_login(self._get_admin())
@@ -291,12 +329,6 @@ def tearDownClass(cls):
291329
super().tearDownClass()
292330
devnull.close()
293331

294-
def _get_device_params(self, org):
295-
p = self._device_params.copy()
296-
p.update(self._additional_params)
297-
p["organization"] = org.pk
298-
return p
299-
300332
def test_device_and_template_different_organization(self):
301333
org1 = self._get_org()
302334
template = self._create_template(organization=org1)
@@ -2251,6 +2283,7 @@ def test_templates_fetch_queries_10(self):
22512283
class TestTransactionAdmin(
22522284
CreateConfigTemplateMixin,
22532285
TestAdminMixin,
2286+
TestDeviceAdminMixin,
22542287
TransactionTestCase,
22552288
):
22562289
app_label = "config"
@@ -2569,6 +2602,152 @@ def test_restoring_template_sends_config_modified(self):
25692602
self.assertEqual(config.status, "modified")
25702603
self.assertNotEqual(config.checksum, config_checksum)
25712604

2605+
@patch.object(Config, "_CONFIG_MODIFIED_TIMEOUT", 3)
2606+
def test_config_modified_signal(self):
2607+
"""
2608+
Verifies multiple config_modified signal are not sent for
2609+
a single change
2610+
"""
2611+
required = self._create_template(
2612+
name="SSH Keys",
2613+
required=True,
2614+
config={
2615+
"files": [
2616+
{
2617+
"path": "/etc/dropbear/authorized_keys",
2618+
"mode": "0600",
2619+
"contents": "ssh-rsa",
2620+
}
2621+
]
2622+
},
2623+
)
2624+
template1 = self._create_template(
2625+
default_values={"ssid": "OpenWISP"}, default=True
2626+
)
2627+
template2 = self._create_template(
2628+
name="template2",
2629+
config={"interfaces": [{"name": "{{ ifname }}", "type": "ethernet"}]},
2630+
default_values={"ifname": "eth1"},
2631+
default=True,
2632+
)
2633+
path = reverse(f"admin:{self.app_label}_device_add")
2634+
params = self._get_device_params(org=self._get_org())
2635+
params.update(
2636+
{"config-0-templates": f"{required.pk},{template1.pk},{template2.pk}"}
2637+
)
2638+
response = self.client.post(path, data=params, follow=True)
2639+
self.assertEqual(response.status_code, 200)
2640+
2641+
config = Device.objects.get(name=params["name"]).config
2642+
self.assertEqual(config.templates.count(), 3)
2643+
path = reverse(f"admin:{self.app_label}_device_change", args=[config.device_id])
2644+
params.update(
2645+
{
2646+
"config-0-id": str(config.pk),
2647+
"config-0-device": str(config.device_id),
2648+
"config-INITIAL_FORMS": 1,
2649+
"_continue": True,
2650+
}
2651+
)
2652+
2653+
config._delete_config_modified_timeout_cache()
2654+
with self.subTest(
2655+
"Updating the unused context variable does not send config_modified signal"
2656+
):
2657+
with patch(
2658+
"openwisp_controller.config.signals.config_modified.send"
2659+
) as mocked_signal:
2660+
params.update(
2661+
{
2662+
"config-0-context": json.dumps(
2663+
{"ssid": "Updated", "ifname": "eth1"}
2664+
)
2665+
}
2666+
)
2667+
response = self.client.post(path, data=params, follow=True)
2668+
self.assertEqual(response.status_code, 200)
2669+
config.refresh_from_db()
2670+
self.assertEqual(config.context["ssid"], "Updated")
2671+
mocked_signal.assert_not_called()
2672+
2673+
config._delete_config_modified_timeout_cache()
2674+
with self.subTest(
2675+
"Changing used context variable sends config_modified signal"
2676+
):
2677+
with patch(
2678+
"openwisp_controller.config.signals.config_modified.send"
2679+
) as mocked_signal:
2680+
params.update(
2681+
{
2682+
"config-0-context": json.dumps(
2683+
{"ssid": "Updated", "ifname": "eth2"}
2684+
)
2685+
}
2686+
)
2687+
response = self.client.post(path, data=params, follow=True)
2688+
self.assertEqual(response.status_code, 200)
2689+
2690+
config.refresh_from_db()
2691+
self.assertEqual(
2692+
config.context["ssid"],
2693+
"Updated",
2694+
)
2695+
self.assertEqual(config.status, "modified")
2696+
mocked_signal.assert_called_once()
2697+
2698+
config._delete_config_modified_timeout_cache()
2699+
with self.subTest("Changing device configuration sends config_modified signal"):
2700+
with patch(
2701+
"openwisp_controller.config.signals.config_modified.send"
2702+
) as mocked_signal:
2703+
params.update(
2704+
{
2705+
"config-0-config": json.dumps(
2706+
{"interfaces": [{"name": "eth5", "type": "ethernet"}]}
2707+
)
2708+
}
2709+
)
2710+
response = self.client.post(path, data=params, follow=True)
2711+
self.assertEqual(response.status_code, 200)
2712+
config.refresh_from_db()
2713+
self.assertEqual(
2714+
config.config["interfaces"][0]["name"],
2715+
"eth5",
2716+
)
2717+
self.assertEqual(config.status, "modified")
2718+
mocked_signal.assert_called_once()
2719+
2720+
config._delete_config_modified_timeout_cache()
2721+
with self.subTest("Changing applied template sends config_modified signal"):
2722+
with patch(
2723+
"openwisp_controller.config.signals.config_modified.send"
2724+
) as mocked_signal:
2725+
response = self.client.post(
2726+
reverse(
2727+
f"admin:{self.app_label}_template_change", args=[template2.pk]
2728+
),
2729+
data={
2730+
"name": template2.name,
2731+
"organization": "",
2732+
"type": template2.type,
2733+
"backend": template2.backend,
2734+
"config": json.dumps(template2.config),
2735+
"default_values": json.dumps(
2736+
{
2737+
"ifname": "eth3",
2738+
}
2739+
),
2740+
"required": False,
2741+
"default": True,
2742+
"_continue": True,
2743+
},
2744+
follow=True,
2745+
)
2746+
self.assertEqual(response.status_code, 200)
2747+
template2.refresh_from_db()
2748+
self.assertEqual(template2.default_values["ifname"], "eth3")
2749+
mocked_signal.assert_called_once()
2750+
25722751

25732752
class TestDeviceGroupAdmin(
25742753
CreateDeviceGroupMixin,

0 commit comments

Comments
 (0)