Skip to content

Commit 309f083

Browse files
authored
[change/fix] Store checksum in DB (fixed perennial modified status) #1113
The config checksum is now stored in the database and the config status is changed to modified only when the checksum changes. Closes #1113
1 parent 29c0634 commit 309f083

File tree

9 files changed

+243
-61
lines changed

9 files changed

+243
-61
lines changed

openwisp_controller/config/apps.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
from .signals import (
2424
config_backend_changed,
2525
config_deactivated,
26-
config_deactivating,
27-
config_modified,
2826
device_group_changed,
2927
device_name_changed,
3028
group_templates_changed,
@@ -285,14 +283,6 @@ def enable_cache_invalidation(self):
285283
DeviceChecksumView.invalidate_get_device_cache_on_config_deactivated,
286284
dispatch_uid="config_deactivated_invalidate_get_device_cache",
287285
)
288-
config_deactivating.connect(
289-
DeviceChecksumView.invalidate_checksum_cache,
290-
dispatch_uid="config_deactivated_invalidate_get_device_cache",
291-
)
292-
config_modified.connect(
293-
DeviceChecksumView.invalidate_checksum_cache,
294-
dispatch_uid="invalidate_checksum_cache",
295-
)
296286
# VPN cache invalidation
297287
post_save.connect(
298288
GetVpnView.invalidate_get_vpn_cache,

openwisp_controller/config/base/config.py

Lines changed: 115 additions & 19 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.invalidate_checksum_cache()
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):
@@ -539,7 +573,9 @@ def clean(self):
539573
def save(self, *args, **kwargs):
540574
created = self._state.adding
541575
# check if config has been modified (so we can emit signals)
542-
if not created:
576+
if created:
577+
self.checksum_db = self.checksum
578+
else:
543579
self._check_changes()
544580
self._just_created = created
545581
result = super().save(*args, **kwargs)
@@ -584,15 +620,63 @@ 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
589-
if self.checksum != current.checksum:
623+
self.update_status_if_checksum_changed(
624+
save=False,
625+
)
626+
627+
def update_status_if_checksum_changed(
628+
self, save=True, update_checksum_db=True, send_config_modified_signal=True
629+
):
630+
"""
631+
Updates the instance status if its checksum has changed.
632+
633+
Returns:
634+
bool: True if the checksum changed and an update was applied,
635+
False otherwise.
636+
"""
637+
checksum_changed = self._has_configuration_checksum_changed()
638+
if checksum_changed:
639+
self.checksum_db = self.checksum
590640
if self.status != "modified":
591-
self.set_status_modified(save=False)
641+
self.set_status_modified(
642+
save=save,
643+
send_config_modified_signal=send_config_modified_signal,
644+
extra_update_fields=["checksum_db"],
645+
)
592646
else:
593-
# config modified signal is always sent
594-
# regardless of the current status
595-
self._send_config_modified_after_save = True
647+
if update_checksum_db:
648+
self._update_checksum_db(new_checksum=self.checksum_db)
649+
if send_config_modified_signal:
650+
self._send_config_modified_after_save = True
651+
self.invalidate_checksum_cache()
652+
return checksum_changed
653+
654+
def _has_configuration_checksum_changed(self):
655+
"""
656+
Determines whether the config checksum has changed
657+
658+
Returns True if:
659+
- No checksum_db exists (first time)
660+
- Current checksum differs from checksum_db
661+
662+
Returns False if:
663+
- Current checksum is the same as checksum_db
664+
"""
665+
if self.checksum_db is None:
666+
# First time or no database checksum, should update
667+
return True
668+
self._invalidate_backend_instance_cache()
669+
return self.checksum_db != self.checksum
670+
671+
def _update_checksum_db(self, new_checksum=None):
672+
"""
673+
Updates checksum_db field in the database
674+
675+
It does not call save() to avoid sending signals
676+
and updating other fields.
677+
"""
678+
new_checksum = new_checksum or self.checksum
679+
self._meta.model.objects.filter(pk=self.pk).update(checksum_db=new_checksum)
596680

597681
def _send_config_modified_signal(self, action):
598682
"""
@@ -668,10 +752,12 @@ def _set_status(self, status, save=True, reason=None, extra_update_fields=None):
668752
if save:
669753
self.save(update_fields=update_fields)
670754

671-
def set_status_modified(self, save=True, send_config_modified_signal=True):
755+
def set_status_modified(
756+
self, save=True, send_config_modified_signal=True, extra_update_fields=None
757+
):
672758
if send_config_modified_signal:
673759
self._send_config_modified_after_save = True
674-
self._set_status("modified", save)
760+
self._set_status("modified", save, extra_update_fields=extra_update_fields)
675761

676762
def set_status_applied(self, save=True):
677763
self._set_status("applied", save)
@@ -697,12 +783,22 @@ def deactivate(self):
697783
"""
698784
# Invalidate cached property before checking checksum.
699785
self._invalidate_backend_instance_cache()
700-
old_checksum = self.checksum
786+
old_checksum = self.checksum_db
787+
# Don't alter the order of the following steps.
788+
# We need to set the status to deactivating before clearing the templates
789+
# otherwise, the "enforce_required_templates" and "add_default_templates"
790+
# methods would re-add required/default templates.
791+
# The "templates_changed" receiver skips post_clear action. Thus,
792+
# we need to update the checksum_db field manually and invalidate
793+
# the cache.
701794
self.config = {}
702-
self.set_status_deactivating()
795+
self.set_status_deactivating(save=False)
703796
self.templates.clear()
704-
del self.backend_instance
705-
if old_checksum == self.checksum:
797+
self._invalidate_backend_instance_cache()
798+
self.checksum_db = self.checksum
799+
self.invalidate_checksum_cache()
800+
self.save()
801+
if old_checksum == self.checksum_db:
706802
# Accelerate deactivation if the configuration remains
707803
# unchanged (i.e. empty configuration)
708804
self.set_status_deactivated()

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
"""

openwisp_controller/config/controller/views.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,6 @@ def invalidate_get_device_cache_on_config_deactivated(cls, instance, **kwargs):
190190
"""
191191
cls.invalidate_get_device_cache(instance=instance.device, **kwargs)
192192

193-
@classmethod
194-
def invalidate_checksum_cache(cls, instance, device, **kwargs):
195-
"""
196-
Called from signal receiver which performs cache invalidation
197-
"""
198-
instance.get_cached_checksum.invalidate(instance)
199-
logger.debug(f"invalidated checksum cache for device ID {device.pk}")
200-
201193

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

openwisp_controller/config/tests/test_config.py

Lines changed: 19 additions & 3 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"
@@ -262,6 +262,15 @@ def test_get_cached_checksum(self):
262262
self.assertEqual(c.get_cached_checksum(), c.checksum)
263263
mocked_debug.assert_called_once()
264264

265+
with self.subTest("cache invalidation works when config is deactivated"):
266+
with patch.object(base_config_logger, "debug") as mocked_debug:
267+
old_checksum = c.checksum
268+
c.deactivate()
269+
del c.backend_instance
270+
self.assertNotEqual(c.checksum, old_checksum)
271+
self.assertEqual(c.get_cached_checksum(), c.checksum)
272+
mocked_debug.assert_called_once()
273+
265274
def test_backend_import_error(self):
266275
"""
267276
see issue #5
@@ -828,8 +837,15 @@ def test_config_modified_sent(self):
828837

829838
def test_check_changes_query(self):
830839
config = self._create_config(organization=self._get_org())
831-
with self.assertNumQueries(10):
832-
config._check_changes()
840+
with self.subTest("No changes made to the config object"):
841+
with self.assertNumQueries(3):
842+
config._check_changes()
843+
844+
with self.subTest("Changes made to the config object"):
845+
config.templates.add(self._create_template())
846+
config.config = {"general": {"description": "test"}}
847+
with self.assertNumQueries(4):
848+
config._check_changes()
833849

834850
def test_config_get_system_context(self):
835851
config = self._create_config(

0 commit comments

Comments
 (0)