Skip to content

Commit 51f2c77

Browse files
committed
[fix] Fixed perennial config modified state #1113
Fixes #1113
1 parent f934ae3 commit 51f2c77

File tree

7 files changed

+207
-34
lines changed

7 files changed

+207
-34
lines changed

openwisp_controller/config/base/config.py

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
from collections import defaultdict
55

6+
from cache_memoize import cache_memoize
67
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
78
from django.db import models, transaction
89
from django.utils.translation import gettext_lazy as _
@@ -100,8 +101,15 @@ class AbstractConfig(ChecksumCacheMixin, BaseConfig):
100101
load_kwargs={"object_pairs_hook": collections.OrderedDict},
101102
dump_kwargs={"indent": 4},
102103
)
104+
checksum_db = models.CharField(
105+
_("configuration checksum"),
106+
max_length=32,
107+
blank=True,
108+
null=True,
109+
help_text=_("Checksum of the generated configuration."),
110+
)
103111

104-
_CHECKSUM_CACHE_TIMEOUT = 60 * 60 * 24 * 30 # 10 days
112+
_CHECKSUM_CACHE_TIMEOUT = ChecksumCacheMixin._CHECKSUM_CACHE_TIMEOUT
105113
_config_context_functions = list()
106114
_old_backend = None
107115

@@ -151,6 +159,32 @@ def key(self):
151159
"""
152160
return self.device.key
153161

162+
@cache_memoize(
163+
timeout=ChecksumCacheMixin._CHECKSUM_CACHE_TIMEOUT,
164+
args_rewrite=get_cached_args_rewrite,
165+
)
166+
def get_cached_checksum(self):
167+
"""
168+
Returns the cached configuration checksum.
169+
170+
Unlike `ChecksumCacheMixin.get_cached_checksum`, this returns the
171+
value from the `checksum_db` field instead of recalculating it.
172+
"""
173+
self.refresh_from_db(fields=["checksum_db"])
174+
return self.checksum_db
175+
176+
@classmethod
177+
def bulk_invalidate_get_cached_checksum(cls, query_params):
178+
"""
179+
Bulk invalidates cached configuration checksums for matching instances
180+
181+
Sets status to modified if the configuration of the instance has changed.
182+
"""
183+
for instance in cls.objects.only("id").filter(**query_params).iterator():
184+
has_changed = instance.update_status_if_checksum_changed()
185+
if has_changed:
186+
instance.get_cached_checksum.invalidate(instance)
187+
154188
@classmethod
155189
def get_template_model(cls):
156190
return cls.templates.rel.model
@@ -276,9 +310,9 @@ def templates_changed(cls, action, instance, **kwargs):
276310
if not instance._just_created:
277311
# sends only config modified signal
278312
instance._send_config_modified_signal(action="m2m_templates_changed")
279-
if instance.status != "modified":
280-
# sends both status modified and config modified signals
281-
instance.set_status_modified(send_config_modified_signal=False)
313+
instance.update_status_if_checksum_changed(
314+
send_config_modified_signal=False
315+
)
282316

283317
@classmethod
284318
def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
@@ -443,7 +477,7 @@ def certificate_updated(cls, instance, created, **kwargs):
443477
except ObjectDoesNotExist:
444478
return
445479
else:
446-
transaction.on_commit(config.set_status_modified)
480+
transaction.on_commit(config.update_status_if_checksum_changed)
447481

448482
@classmethod
449483
def register_context_function(cls, func):
@@ -541,6 +575,8 @@ def save(self, *args, **kwargs):
541575
# check if config has been modified (so we can emit signals)
542576
if not created:
543577
self._check_changes()
578+
self._invalidate_backend_instance_cache()
579+
self.checksum_db = self.checksum
544580
self._just_created = created
545581
result = super().save(*args, **kwargs)
546582
# add default templates if config has just been created
@@ -584,8 +620,7 @@ def _check_changes(self):
584620
if self.backend != current.backend:
585621
# storing old backend to send backend change signal after save
586622
self._old_backend = current.backend
587-
if hasattr(self, "backend_instance"):
588-
del self.backend_instance
623+
self._invalidate_backend_instance_cache()
589624
if self.checksum != current.checksum:
590625
if self.status != "modified":
591626
self.set_status_modified(save=False)
@@ -594,6 +629,52 @@ def _check_changes(self):
594629
# regardless of the current status
595630
self._send_config_modified_after_save = True
596631

632+
def update_status_if_checksum_changed(
633+
self, save=True, send_config_modified_signal=True
634+
):
635+
"""
636+
Updates the instance status if its checksum has changed.
637+
638+
Returns:
639+
bool: True if the checksum changed and an update was applied,
640+
False otherwise.
641+
"""
642+
checksum_changed = self._should_update_status_based_on_checksum()
643+
if checksum_changed:
644+
self.checksum_db = self.checksum
645+
if self.status != "modified":
646+
self.set_status_modified(
647+
save=save,
648+
send_config_modified_signal=send_config_modified_signal,
649+
extra_update_fields=["checksum_db"],
650+
)
651+
else:
652+
# Instead of calling the "save()" method, which would
653+
# trigger various signals and checks, we directly update
654+
# the "checksum_db" field in the database.
655+
self._meta.model.objects.filter(pk=self.pk).update(
656+
checksum_db=self.checksum_db
657+
)
658+
return checksum_changed
659+
660+
def _should_update_status_based_on_checksum(self):
661+
"""
662+
Determines whether the config status should be updated based on
663+
checksum comparison.
664+
665+
Returns True if:
666+
- No checksum_db exists (first time)
667+
- Current checksum differs from checksum_db
668+
669+
Returns False if:
670+
- Current checksum is the same as checksum_db
671+
"""
672+
if self.checksum_db is None:
673+
# First time or no database checksum, should update
674+
return True
675+
self._invalidate_backend_instance_cache()
676+
return self.checksum_db != self.checksum
677+
597678
def _send_config_modified_signal(self, action):
598679
"""
599680
Emits ``config_modified`` signal.
@@ -668,10 +749,12 @@ def _set_status(self, status, save=True, reason=None, extra_update_fields=None):
668749
if save:
669750
self.save(update_fields=update_fields)
670751

671-
def set_status_modified(self, save=True, send_config_modified_signal=True):
752+
def set_status_modified(
753+
self, save=True, send_config_modified_signal=True, extra_update_fields=None
754+
):
672755
if send_config_modified_signal:
673756
self._send_config_modified_after_save = True
674-
self._set_status("modified", save)
757+
self._set_status("modified", save, extra_update_fields)
675758

676759
def set_status_applied(self, save=True):
677760
self._set_status("applied", save)

openwisp_controller/config/base/template.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,18 @@ def _update_related_config_status(self):
162162
# be executed via transaction.on_commit
163163
# is executed after the whole block
164164
with transaction.atomic():
165-
changing_status = list(
166-
self.config_relations.exclude(status="modified").values_list(
167-
"pk", flat=True
165+
for config in (
166+
self.config_relations.prefetch_related(
167+
"vpnclient_set",
168+
"templates",
169+
)
170+
.select_related("device", "device__organization__config_settings")
171+
.iterator(chunk_size=1000)
172+
):
173+
config.update_status_if_checksum_changed(
174+
send_config_modified_signal=False
168175
)
169-
)
170-
# flag all related configs as modified with 1 update query
171-
self.config_relations.exclude(status="modified").update(status="modified")
172-
# the following loop should not take too long
173-
for config in self.config_relations.select_related("device").iterator():
174-
# config modified signal sent regardless
175176
config._send_config_modified_signal(action="related_template_changed")
176-
# config status changed signal sent only if status changed
177-
if config.pk in changing_status:
178-
config._send_config_status_changed_signal()
179177

180178
def _auto_add_to_existing_configs(self):
181179
"""
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django for issue #1113 optimization
2+
3+
from django.db import migrations, models
4+
5+
6+
def populate_checksum_db(apps, schema_editor):
7+
"""
8+
Populate checksum_db field with current checksum values
9+
for existing Config objects.
10+
"""
11+
Config = apps.get_model("config", "Config")
12+
batch = []
13+
batch_size = 500
14+
qs = Config.objects.filter(checksum_db__isnull=True).iterator()
15+
for config in qs:
16+
config.checksum_db = config.checksum
17+
batch.append(config)
18+
if len(batch) >= batch_size:
19+
Config.objects.bulk_update(batch, ["checksum_db"])
20+
batch = []
21+
if batch:
22+
Config.objects.bulk_update(batch, ["checksum_db"])
23+
24+
25+
class Migration(migrations.Migration):
26+
dependencies = [
27+
("config", "0060_cleanup_api_task_notification_types"),
28+
]
29+
30+
operations = [
31+
migrations.AddField(
32+
model_name="config",
33+
name="checksum_db",
34+
field=models.CharField(
35+
blank=True,
36+
help_text=("Checksum of the generated configuration."),
37+
max_length=32,
38+
null=True,
39+
verbose_name="configuration checksum",
40+
),
41+
),
42+
migrations.RunPython(
43+
populate_checksum_db,
44+
reverse_code=migrations.RunPython.noop,
45+
),
46+
]

openwisp_controller/config/tests/test_config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def test_get_cached_checksum(self):
232232
with patch.object(base_config_logger, "debug") as mocked_debug:
233233
c.get_cached_checksum.invalidate(c)
234234
self.assertEqual(len(c.get_cached_checksum()), 32)
235-
mocked_debug.assert_called_once()
235+
mocked_debug.assert_not_called()
236236

237237
with self.subTest(
238238
"ensure fresh checksum is NOT calculated when cache is present"
@@ -250,7 +250,7 @@ def test_get_cached_checksum(self):
250250
del c.backend_instance
251251
self.assertNotEqual(c.checksum, old_checksum)
252252
self.assertEqual(c.get_cached_checksum(), c.checksum)
253-
mocked_debug.assert_called_once()
253+
mocked_debug.assert_not_called()
254254

255255
with self.subTest("test cache invalidation when config templates are changed"):
256256
with patch.object(base_config_logger, "debug") as mocked_debug:
@@ -260,7 +260,7 @@ def test_get_cached_checksum(self):
260260
del c.backend_instance
261261
self.assertNotEqual(c.checksum, old_checksum)
262262
self.assertEqual(c.get_cached_checksum(), c.checksum)
263-
mocked_debug.assert_called_once()
263+
mocked_debug.assert_not_called()
264264

265265
def test_backend_import_error(self):
266266
"""
@@ -962,7 +962,7 @@ def test_certificate_renew_invalidates_checksum_cache(self):
962962
vpnclient_cert.renew()
963963
# An additional call from cache invalidation of
964964
# DeviceGroupCommonName View
965-
self.assertEqual(mocked_delete.call_count, 3)
965+
self.assertEqual(mocked_delete.call_count, 2)
966966
del config.backend_instance
967967
self.assertNotEqual(config.get_cached_checksum(), old_checksum)
968968
config.refresh_from_db()

openwisp_controller/config/tests/test_controller.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def test_get_cached_checksum(self):
181181
url = reverse("controller:device_checksum", args=[d.pk])
182182

183183
with self.subTest("first request does not return value from cache"):
184-
with self.assertNumQueries(4):
184+
with self.assertNumQueries(3):
185185
with patch.object(
186186
controller_views_logger, "debug"
187187
) as mocked_view_debug:
@@ -191,7 +191,7 @@ def test_get_cached_checksum(self):
191191
self.client.get(
192192
url, {"key": d.key, "management_ip": "10.0.0.2"}
193193
)
194-
self.assertEqual(mocked_config_debug.call_count, 1)
194+
self.assertEqual(mocked_config_debug.call_count, 0)
195195
self.assertEqual(mocked_view_debug.call_count, 1)
196196

197197
with self.subTest("update_last_ip updates the cache"):
@@ -240,7 +240,7 @@ def test_get_cached_checksum(self):
240240
del d.config.backend_instance
241241
self.assertNotEqual(d.config.checksum, old_checksum)
242242
self.assertEqual(d.config.get_cached_checksum(), d.config.checksum)
243-
mocked_debug.assert_called_once()
243+
mocked_debug.assert_not_called()
244244

245245
def test_device_checksum_requested_signal_is_emitted(self):
246246
d = self._create_device_config()
@@ -1394,12 +1394,12 @@ def test_ip_fields_not_duplicated(self):
13941394
c2 = self._create_config(device=d2)
13951395
org2 = self._create_org(name="org2", shared_secret="123456")
13961396
c3 = self._create_config(organization=org2)
1397-
with self.assertNumQueries(7):
1397+
with self.assertNumQueries(6):
13981398
self.client.get(
13991399
reverse("controller:device_checksum", args=[c3.device.pk]),
14001400
{"key": c3.device.key, "management_ip": "192.168.1.99"},
14011401
)
1402-
with self.assertNumQueries(7):
1402+
with self.assertNumQueries(6):
14031403
self.client.get(
14041404
reverse("controller:device_checksum", args=[c1.device.pk]),
14051405
{"key": c1.device.key, "management_ip": "192.168.1.99"},
@@ -1441,14 +1441,14 @@ def test_organization_shares_management_ip_address_space(self):
14411441
org1_config = self._create_config(organization=org1)
14421442
org2 = self._create_org(name="org2", shared_secret="org2")
14431443
org2_config = self._create_config(organization=org2)
1444-
with self.assertNumQueries(7):
1444+
with self.assertNumQueries(6):
14451445
self.client.get(
14461446
reverse("controller:device_checksum", args=[org1_config.device_id]),
14471447
{"key": org1_config.device.key, "management_ip": "192.168.1.99"},
14481448
)
14491449
# Device from another organization sends conflicting management IP
14501450
# Extra queries due to conflict resolution
1451-
with self.assertNumQueries(9):
1451+
with self.assertNumQueries(8):
14521452
self.client.get(
14531453
reverse("controller:device_checksum", args=[org2_config.device_id]),
14541454
{"key": org2_config.device.key, "management_ip": "192.168.1.99"},

0 commit comments

Comments
 (0)