Skip to content

Commit 9750cfb

Browse files
committed
add cached count fields to avoid expensive COUNT queries
- Add sec_updates_count, bug_updates_count, packages_count, errata_count to Host - Add packages_count to Mirror - Add hosts_count to OSVariant - Add Django signals to auto-update counts on M2M/FK changes - Update views to use cached fields instead of annotations
1 parent 23998d6 commit 9750cfb

24 files changed

Lines changed: 339 additions & 37 deletions

hosts/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@
1919

2020
class HostsConfig(AppConfig):
2121
name = 'hosts'
22+
23+
def ready(self):
24+
import hosts.signals # noqa
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.28 on 2026-02-11 04:56
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('hosts', '0010_alter_hostrepo_options'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='host',
15+
name='bug_updates_count',
16+
field=models.PositiveIntegerField(db_index=True, default=0),
17+
),
18+
migrations.AddField(
19+
model_name='host',
20+
name='errata_count',
21+
field=models.PositiveIntegerField(db_index=True, default=0),
22+
),
23+
migrations.AddField(
24+
model_name='host',
25+
name='packages_count',
26+
field=models.PositiveIntegerField(db_index=True, default=0),
27+
),
28+
migrations.AddField(
29+
model_name='host',
30+
name='sec_updates_count',
31+
field=models.PositiveIntegerField(db_index=True, default=0),
32+
),
33+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 4.2.28 on 2026-02-11
2+
3+
from django.db import migrations
4+
5+
6+
def backfill_host_counts(apps, schema_editor):
7+
"""Backfill cached count fields for existing hosts."""
8+
Host = apps.get_model('hosts', 'Host')
9+
for host in Host.objects.all():
10+
host.sec_updates_count = host.updates.filter(security=True).count()
11+
host.bug_updates_count = host.updates.filter(security=False).count()
12+
host.packages_count = host.packages.count()
13+
host.errata_count = host.errata.count()
14+
host.save(update_fields=[
15+
'sec_updates_count', 'bug_updates_count',
16+
'packages_count', 'errata_count'
17+
])
18+
19+
20+
def reverse_backfill(apps, schema_editor):
21+
"""No-op reverse - counts will be recalculated on next report."""
22+
pass
23+
24+
25+
class Migration(migrations.Migration):
26+
27+
dependencies = [
28+
('hosts', '0011_host_bug_updates_count_host_errata_count_and_more'),
29+
]
30+
31+
operations = [
32+
migrations.RunPython(backfill_host_counts, reverse_backfill),
33+
]

hosts/models.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ class Host(models.Model):
6060
tags = TaggableManager(blank=True)
6161
updated_at = models.DateTimeField(default=timezone.now)
6262
errata = models.ManyToManyField(Erratum, blank=True)
63+
# Cached count fields for query optimization
64+
sec_updates_count = models.PositiveIntegerField(default=0, db_index=True)
65+
bug_updates_count = models.PositiveIntegerField(default=0, db_index=True)
66+
packages_count = models.PositiveIntegerField(default=0, db_index=True)
67+
errata_count = models.PositiveIntegerField(default=0, db_index=True)
6368

6469
from hosts.managers import HostManager
6570
objects = HostManager()
@@ -97,20 +102,23 @@ def get_absolute_url(self):
97102
return reverse('hosts:host_detail', args=[self.hostname])
98103

99104
def get_num_security_updates(self):
100-
return self.updates.filter(security=True).count()
105+
return self.sec_updates_count
101106

102107
def get_num_bugfix_updates(self):
103-
return self.updates.filter(security=False).count()
108+
return self.bug_updates_count
104109

105110
def get_num_updates(self):
106-
return self.updates.count()
111+
return self.sec_updates_count + self.bug_updates_count
107112

108113
def get_num_packages(self):
109-
return self.packages.count()
114+
return self.packages_count
110115

111116
def get_num_repos(self):
112117
return self.repos.count()
113118

119+
def get_num_errata(self):
120+
return self.errata_count
121+
114122
def check_rdns(self):
115123
if self.check_dns:
116124
update_rdns(self)

hosts/serializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ class Meta:
3333
'updated_at', 'bugfix_update_count', 'security_update_count')
3434

3535
def get_bugfix_update_count(self, obj):
36-
return obj.updates.filter(security=False).count()
36+
return obj.bug_updates_count
3737

3838
def get_security_update_count(self, obj):
39-
return obj.updates.filter(security=True).count()
39+
return obj.sec_updates_count
4040

4141

4242
class HostRepoSerializer(serializers.HyperlinkedModelSerializer):

hosts/signals.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
2+
#
3+
# This file is part of Patchman.
4+
#
5+
# Patchman is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, version 3 only.
8+
#
9+
# Patchman is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with Patchman. If not, see <http://www.gnu.org/licenses/>
16+
17+
from django.db.models.signals import m2m_changed
18+
from django.dispatch import receiver
19+
20+
from hosts.models import Host
21+
22+
23+
@receiver(m2m_changed, sender=Host.packages.through)
24+
def update_host_packages_count(sender, instance, action, **kwargs):
25+
"""Update packages_count when Host.packages M2M changes."""
26+
if action in ('post_add', 'post_remove', 'post_clear'):
27+
instance.packages_count = instance.packages.count()
28+
instance.save(update_fields=['packages_count'])
29+
30+
31+
@receiver(m2m_changed, sender=Host.updates.through)
32+
def update_host_updates_count(sender, instance, action, **kwargs):
33+
"""Update sec_updates_count and bug_updates_count when Host.updates M2M changes."""
34+
if action in ('post_add', 'post_remove', 'post_clear'):
35+
instance.sec_updates_count = instance.updates.filter(security=True).count()
36+
instance.bug_updates_count = instance.updates.filter(security=False).count()
37+
instance.save(update_fields=['sec_updates_count', 'bug_updates_count'])
38+
39+
40+
@receiver(m2m_changed, sender=Host.errata.through)
41+
def update_host_errata_count(sender, instance, action, **kwargs):
42+
"""Update errata_count when Host.errata M2M changes."""
43+
if action in ('post_add', 'post_remove', 'post_clear'):
44+
instance.errata_count = instance.errata.count()
45+
instance.save(update_fields=['errata_count'])

hosts/tables.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
'{% endwith %}'
3232
)
3333
AFFECTED_ERRATA_TEMPLATE = (
34-
'{% with count=record.errata.count %}'
34+
'{% with count=record.errata_count %}'
3535
'{% if count != 0 %}'
3636
'<a href="{% url \'errata:erratum_list\' %}?host={{ record.hostname }}">{{ count }}</a>'
3737
'{% else %}{% endif %}{% endwith %}'

hosts/tasks.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
# along with Patchman. If not, see <http://www.gnu.org/licenses/>
1616

1717
from celery import shared_task
18-
from django.db.models import Count
1918

2019
from hosts.models import Host
2120
from util import get_datetime_now
@@ -51,10 +50,9 @@ def find_all_host_updates_homogenous():
5150
host.save()
5251

5352
# only include hosts with the exact same number of packages
54-
filtered_hosts = Host.objects.annotate(
55-
packages_count=Count('packages')).filter(
56-
packages_count=host.packages.count()
57-
)
53+
filtered_hosts = Host.objects.filter(
54+
packages_count=host.packages_count
55+
)
5856
# and exclude hosts with the current timestamp
5957
filtered_hosts = filtered_hosts.exclude(updated_at=ts)
6058

hosts/views.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from django.contrib import messages
1919
from django.contrib.auth.decorators import login_required
20-
from django.db.models import Count, Q
20+
from django.db.models import Q
2121
from django.shortcuts import get_object_or_404, redirect, render
2222
from django.urls import reverse
2323
from django_filters import rest_framework as filters
@@ -76,12 +76,8 @@ def _get_filtered_hosts(filter_params):
7676

7777
@login_required
7878
def host_list(request):
79-
hosts = Host.objects.select_related().annotate(
80-
sec_updates_count=Count('updates', filter=Q(updates__security=True), distinct=True),
81-
bug_updates_count=Count('updates', filter=Q(updates__security=False), distinct=True),
82-
errata_count=Count('errata', distinct=True),
83-
packages_count=Count('packages', distinct=True),
84-
)
79+
# Use cached count fields instead of expensive annotations
80+
hosts = Host.objects.select_related()
8581

8682
if 'domain_id' in request.GET:
8783
hosts = hosts.filter(domain=request.GET['domain_id'])

operatingsystems/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@
1919

2020
class OperatingsystemsConfig(AppConfig):
2121
name = 'operatingsystems'
22+
23+
def ready(self):
24+
import operatingsystems.signals # noqa

0 commit comments

Comments
 (0)