Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion netbox_docker_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class NetBoxDockerConfig(PluginConfig):
name = "netbox_docker_plugin"
verbose_name = " NetBox Docker Plugin"
description = "Manage Docker"
version = "5.1.0"
version = "5.1.1"
base_url = "docker"
min_version = "4.5.0"
author = "Vincent Simonin <vincent@saashup.com>, David Delassus <david.jose.delassus@gmail.com>"
Expand Down
20 changes: 17 additions & 3 deletions netbox_docker_plugin/models/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@

# pylint: disable=E1101

from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.functions import Lower
from django.urls import reverse
from django.core.validators import (
MinLengthValidator,
MaxLengthValidator,
MinValueValidator,
MaxValueValidator,
)
from django.db import models
from django.db.models.functions import Lower
from django.urls import reverse
from extras.models import JournalEntry
from utilities.choices import ChoiceSet
from utilities.querysets import RestrictedQuerySet
from netbox.models import NetBoxModel
Expand Down Expand Up @@ -306,6 +308,18 @@ class Meta:
),
)

def delete(self, using=None, keep_parents=False):
ct = ContentType.objects.get_for_model(self)
qs = JournalEntry.objects.filter(
assigned_object_type=ct,
assigned_object_id=self.pk,
)
# _raw_delete issues a single SQL DELETE bypassing Django's Collector,
# avoiding per-row post_delete signals (ObjectChange writes) that make
# bulk journal cleanup O(n) slow.
qs._raw_delete(qs.db) # pylint: disable=protected-access
return super().delete(using, keep_parents)

def __str__(self):
return f"{self.name}"

Expand Down
137 changes: 137 additions & 0 deletions netbox_docker_plugin/tests/container/test_container_delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Container deletion tests — journal entry cleanup"""

import time

from django.contrib.contenttypes.models import ContentType
from django.test import TestCase

from extras.models import JournalEntry
from netbox_docker_plugin.models.container import Container
from netbox_docker_plugin.models.host import Host
from netbox_docker_plugin.models.image import Image
from netbox_docker_plugin.models.registry import Registry


def _make_journal_entries(container, count):
ct = ContentType.objects.get_for_model(Container)
batch_size = 10000
for start in range(0, count, batch_size):
JournalEntry.objects.bulk_create(
[
JournalEntry(
assigned_object_type=ct,
assigned_object_id=container.pk,
created_by=None,
kind="info",
comments=f"entry {start + i}",
)
for i in range(min(batch_size, count - start))
]
)


class ContainerDeleteJournalTestCase(TestCase):
"""Verify that container deletion cleans up journal entries in batches."""

@classmethod
def setUpTestData(cls):
cls.host = Host.objects.create(endpoint="http://localhost:8080", name="host1")
cls.registry = Registry.objects.create(
host=cls.host,
name="registry1",
serveraddress="http://localhost:8080",
)
cls.image = Image.objects.create(
host=cls.host, name="image1", registry=cls.registry
)

def _make_container(self, name="container1", state="exited"):
"""Create and return a Container in the given state."""
return Container.objects.create(
host=self.host,
image=self.image,
name=name,
operation="none",
state=state,
)

def test_delete_container_with_no_journal_entries(self):
"""Container with no journal entries can be deleted without errors."""
container = self._make_container()
pk = container.pk
t0 = time.monotonic()
container.delete()
print(
f"Deleted container (0 journal entries) in {(time.monotonic() - t0) * 1000:.1f}ms"
)

self.assertFalse(Container.objects.filter(pk=pk).exists())

def test_delete_container_removes_journal_entries(self):
"""Journal entries linked to the deleted container are removed."""
container = self._make_container(name="container2")
_make_journal_entries(container, 5)
ct = ContentType.objects.get_for_model(Container)

self.assertEqual(
JournalEntry.objects.filter(
assigned_object_type=ct, assigned_object_id=container.pk
).count(),
5,
)

pk = container.pk
t0 = time.monotonic()
container.delete()
print(
f"Deleted container (5 journal entries) in {(time.monotonic() - t0) * 1000:.1f}ms"
)

self.assertFalse(Container.objects.filter(pk=pk).exists())
self.assertEqual(
JournalEntry.objects.filter(
assigned_object_type=ct, assigned_object_id=pk
).count(),
0,
)

def test_delete_container_removes_many_journal_entries(self):
"""Deletion removes all journal entries regardless of count."""
container = self._make_container(name="container3")
_make_journal_entries(container, 500000)
ct = ContentType.objects.get_for_model(Container)

pk = container.pk
t0 = time.monotonic()
container.delete()
elapsed = (time.monotonic() - t0) * 1000
print(f"Deleted container (500000 journal entries) in {elapsed:.1f}ms")

self.assertFalse(Container.objects.filter(pk=pk).exists())
self.assertEqual(
JournalEntry.objects.filter(
assigned_object_type=ct, assigned_object_id=pk
).count(),
0,
)

def test_delete_does_not_remove_journal_entries_of_other_containers(self):
"""Deleting one container must not remove journal entries of another."""
container_a = self._make_container(name="container4a")
container_b = self._make_container(name="container4b")
_make_journal_entries(container_a, 3)
_make_journal_entries(container_b, 3)
ct = ContentType.objects.get_for_model(Container)

t0 = time.monotonic()
container_a.delete()
print(
f"Deleted container (3 journal entries) in {(time.monotonic() - t0) * 1000:.1f}ms"
)

self.assertEqual(
JournalEntry.objects.filter(
assigned_object_type=ct, assigned_object_id=container_b.pk
).count(),
3,
)
61 changes: 60 additions & 1 deletion netbox_docker_plugin/views/container.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Container views definitions"""

from collections import defaultdict
from django.db import router
from django.db.models.deletion import Collector
from extras.models import JournalEntry
from utilities.query import count_related
from utilities.views import ViewTab, register_model_view
from netbox.views import generic
Expand Down Expand Up @@ -125,11 +129,66 @@ class ContainerBulkDeleteView(generic.BulkDeleteView):
table = tables.ContainerTable


class _JournalEntryCount:
"""Proxy passed to the deletion template in place of a full JournalEntry list.

The template calls len() for the accordion header count, then iterates to
render individual rows. This object answers len() with a single COUNT query
and yields nothing on iteration, avoiding fetching hundreds of thousands of
rows just to display a number."""

def __init__(self, queryset):
self._queryset = queryset
self._count = None

def __len__(self):
if self._count is None:
self._count = self._queryset.count()
return self._count

def __iter__(self):
return iter([])

def __bool__(self):
return len(self) > 0


class ContainerDeleteView(generic.ObjectDeleteView):
"""Container delete view definition"""

default_return_url = "plugins:netbox_docker_plugin:container_list"
queryset = Container.objects.all()
queryset = Container.objects.select_related("host", "image")

def _get_dependent_objects(self, obj):
class SkipJournalCollector(Collector):
"""Collector that skips JournalEntry to avoid a slow full scan."""

def collect(self, objs, **kwargs): # pylint: disable=arguments-differ
if getattr(objs, "model", None) is JournalEntry:
return
super().collect(objs, **kwargs)

using = router.db_for_write(obj._meta.model)
collector = SkipJournalCollector(using=using)
collector.collect([obj])

dependent_objects = defaultdict(list)
for model, instances in collector.instances_with_model():
if model._meta.auto_created:
continue
if instances == obj:
continue
dependent_objects[model].append(instances)

journal_qs = JournalEntry.objects.filter(
assigned_object_type__app_label="netbox_docker_plugin",
assigned_object_type__model="container",
assigned_object_id=obj.pk,
)
if journal_qs.exists():
dependent_objects[JournalEntry] = _JournalEntryCount(journal_qs)

return dict(dependent_objects)


class ContainerOperationView(generic.ObjectEditView):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "netbox-docker-plugin"
version = "5.1.0"
version = "5.1.1"
authors = [
{ name="Vincent Simonin", email="vincent@saashup.com" },
{ name="David Delassus", email="david.jose.delassus@gmail.com" }
Expand Down
Loading