Skip to content

Commit 7ca0c1d

Browse files
authored
245 cache timestamp (#318)
1 parent 243d549 commit 7ca0c1d

File tree

4 files changed

+136
-59
lines changed

4 files changed

+136
-59
lines changed

netbox_custom_objects/__init__.py

Lines changed: 79 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,13 @@
11
import sys
22
import warnings
33

4+
from django.db import transaction
45
from django.db.utils import DatabaseError, OperationalError, ProgrammingError
56
from netbox.plugins import PluginConfig
67

78
from .constants import APP_LABEL as APP_LABEL
89

910

10-
def is_running_migration():
11-
"""
12-
Check if the code is currently running during a Django migration.
13-
"""
14-
# Check if 'makemigrations' or 'migrate' command is in sys.argv
15-
if any(cmd in sys.argv for cmd in ["makemigrations", "migrate"]):
16-
return True
17-
18-
return False
19-
20-
21-
def check_custom_object_type_table_exists():
22-
"""
23-
Check if the CustomObjectType table exists in the database.
24-
Returns True if the table exists, False otherwise.
25-
"""
26-
from django.db import connection
27-
from .models import CustomObjectType
28-
29-
try:
30-
# Use raw SQL to check table existence without generating ORM errors
31-
with connection.cursor() as cursor:
32-
table_name = CustomObjectType._meta.db_table
33-
cursor.execute("""
34-
SELECT EXISTS (
35-
SELECT FROM information_schema.tables
36-
WHERE table_name = %s
37-
)
38-
""", [table_name])
39-
table_exists = cursor.fetchone()[0]
40-
return table_exists
41-
except (OperationalError, ProgrammingError, DatabaseError):
42-
# Catch database-specific errors (permission issues, etc.)
43-
return False
44-
45-
4611
# Plugin Configuration
4712
class CustomObjectsPluginConfig(PluginConfig):
4813
name = "netbox_custom_objects"
@@ -60,6 +25,47 @@ class CustomObjectsPluginConfig(PluginConfig):
6025
required_settings = []
6126
template_extensions = "template_content.template_extensions"
6227

28+
@staticmethod
29+
def _is_running_migration():
30+
"""
31+
Check if the code is currently running during a Django migration.
32+
"""
33+
# Check if 'makemigrations' or 'migrate' command is in sys.argv
34+
return any(cmd in sys.argv for cmd in ["makemigrations", "migrate"])
35+
36+
@staticmethod
37+
def _is_running_test():
38+
"""
39+
Check if the code is currently running during Django tests.
40+
"""
41+
# Check if 'test' command is in sys.argv
42+
return "test" in sys.argv
43+
44+
@staticmethod
45+
def _check_custom_object_type_table_exists():
46+
"""
47+
Check if the CustomObjectType table exists in the database.
48+
Returns True if the table exists, False otherwise.
49+
"""
50+
from django.db import connection
51+
from .models import CustomObjectType
52+
53+
try:
54+
# Use raw SQL to check table existence without generating ORM errors
55+
with connection.cursor() as cursor:
56+
table_name = CustomObjectType._meta.db_table
57+
cursor.execute("""
58+
SELECT EXISTS (
59+
SELECT FROM information_schema.tables
60+
WHERE table_name = %s
61+
)
62+
""", [table_name])
63+
table_exists = cursor.fetchone()[0]
64+
return table_exists
65+
except (OperationalError, ProgrammingError, DatabaseError):
66+
# Catch database-specific errors (permission issues, etc.)
67+
return False
68+
6369
def ready(self):
6470
from .models import CustomObjectType
6571
from netbox_custom_objects.api.serializers import get_serializer_class
@@ -74,14 +80,24 @@ def ready(self):
7480
)
7581

7682
# Skip database calls if running during migration or if table doesn't exist
77-
if is_running_migration() or not check_custom_object_type_table_exists():
83+
if self._is_running_migration() or not self._check_custom_object_type_table_exists():
7884
super().ready()
7985
return
8086

81-
qs = CustomObjectType.objects.all()
82-
for obj in qs:
83-
model = obj.get_model()
84-
get_serializer_class(model)
87+
try:
88+
with transaction.atomic():
89+
qs = CustomObjectType.objects.all()
90+
for obj in qs:
91+
model = obj.get_model()
92+
get_serializer_class(model)
93+
except (DatabaseError, OperationalError, ProgrammingError):
94+
# Only suppress exceptions during tests when schema may not match model
95+
# During normal operation, re-raise to alert of actual problems
96+
if self._is_running_test():
97+
# The transaction.atomic() block will automatically rollback
98+
pass
99+
else:
100+
raise
85101

86102
super().ready()
87103

@@ -132,22 +148,33 @@ def get_models(self, include_auto_created=False, include_swapped=False):
132148
)
133149

134150
# Skip custom object type model loading if running during migration
135-
if is_running_migration() or not check_custom_object_type_table_exists():
151+
if self._is_running_migration() or not self._check_custom_object_type_table_exists():
136152
return
137153

138154
# Add custom object type models
139155
from .models import CustomObjectType
140156

141-
custom_object_types = CustomObjectType.objects.all()
142-
for custom_type in custom_object_types:
143-
model = custom_type.get_model()
144-
if model:
145-
yield model
146-
147-
# If include_auto_created is True, also yield through models
148-
if include_auto_created and hasattr(model, '_through_models'):
149-
for through_model in model._through_models:
150-
yield through_model
157+
try:
158+
with transaction.atomic():
159+
custom_object_types = CustomObjectType.objects.all()
160+
for custom_type in custom_object_types:
161+
model = custom_type.get_model()
162+
if model:
163+
yield model
164+
165+
# If include_auto_created is True, also yield through models
166+
if include_auto_created and hasattr(model, '_through_models'):
167+
for through_model in model._through_models:
168+
yield through_model
169+
except (DatabaseError, OperationalError, ProgrammingError):
170+
# Only suppress exceptions during tests when schema may not match model
171+
# (e.g., cache_timestamp column doesn't exist yet during test setup)
172+
# During normal operation, re-raise to alert of actual problems
173+
if self._is_running_test():
174+
# The transaction.atomic() block will automatically rollback
175+
pass
176+
else:
177+
raise
151178

152179

153180
config = CustomObjectsPluginConfig
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.8 on 2025-12-05 00:38
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("netbox_custom_objects", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="customobjecttype",
15+
name="cache_timestamp",
16+
field=models.DateTimeField(auto_now=True),
17+
),
18+
]

netbox_custom_objects/migrations/0002_ensure_fk_constraints.py renamed to netbox_custom_objects/migrations/0003_ensure_fk_constraints.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def ensure_existing_fk_constraints(apps, schema_editor):
2020
class Migration(migrations.Migration):
2121

2222
dependencies = [
23-
('netbox_custom_objects', '0001_initial'),
23+
('netbox_custom_objects', '0002_customobjecttype_cache_timestamp'),
2424
]
2525

2626
operations = [

netbox_custom_objects/models.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ class CustomObjectType(NetBoxModel):
200200
verbose_name = models.CharField(max_length=100, blank=True)
201201
verbose_name_plural = models.CharField(max_length=100, blank=True)
202202
slug = models.SlugField(max_length=100, unique=True, db_index=True)
203+
cache_timestamp = models.DateTimeField(
204+
auto_now=True,
205+
help_text=_("Timestamp used for cache invalidation")
206+
)
203207
object_type = models.OneToOneField(
204208
ObjectType,
205209
on_delete=models.CASCADE,
@@ -265,7 +269,25 @@ def get_cached_model(cls, custom_object_type_id):
265269
:param custom_object_type_id: ID of the CustomObjectType
266270
:return: The cached model or None if not found
267271
"""
268-
return cls._model_cache.get(custom_object_type_id)
272+
cache_entry = cls._model_cache.get(custom_object_type_id)
273+
if cache_entry:
274+
# Cache stores (model, timestamp) tuples
275+
return cache_entry[0]
276+
return None
277+
278+
@classmethod
279+
def get_cached_timestamp(cls, custom_object_type_id):
280+
"""
281+
Get the timestamp of a cached model for a specific CustomObjectType.
282+
283+
:param custom_object_type_id: ID of the CustomObjectType
284+
:return: The cached timestamp or None if not found
285+
"""
286+
cache_entry = cls._model_cache.get(custom_object_type_id)
287+
if cache_entry:
288+
# Cache stores (model, timestamp) tuples
289+
return cache_entry[1]
290+
return None
269291

270292
@classmethod
271293
def is_model_cached(cls, custom_object_type_id):
@@ -468,11 +490,15 @@ def get_model(
468490
:rtype: Model
469491
"""
470492

471-
# Double-check pattern: check cache again after acquiring lock
472493
with self._global_lock:
473494
if self.is_model_cached(self.id) and not no_cache:
474-
model = self.get_cached_model(self.id)
475-
return model
495+
cached_timestamp = self.get_cached_timestamp(self.id)
496+
# Only use cache if the timestamps are available and match
497+
if cached_timestamp and self.cache_timestamp and cached_timestamp == self.cache_timestamp:
498+
model = self.get_cached_model(self.id)
499+
return model
500+
else:
501+
self.clear_model_cache(self.id)
476502

477503
# Generate the model outside the lock to avoid holding it during expensive operations
478504
model_name = self.get_table_model_name(self.pk)
@@ -547,9 +573,9 @@ def wrapped_post_through_setup(self, cls):
547573

548574
self._after_model_generation(attrs, model)
549575

550-
# Cache the generated model (protected by lock for thread safety)
576+
# Cache the generated model with its timestamp (protected by lock for thread safety)
551577
with self._global_lock:
552-
self._model_cache[self.id] = model
578+
self._model_cache[self.id] = (model, self.cache_timestamp)
553579

554580
# Do the clear cache now that we have it in the cache so there
555581
# is no recursion.
@@ -1594,6 +1620,9 @@ def save(self, *args, **kwargs):
15941620
# Clear and refresh the model cache for this CustomObjectType when a field is modified
15951621
self.custom_object_type.clear_model_cache(self.custom_object_type.id)
15961622

1623+
# Update parent's cache_timestamp to invalidate cache across all workers
1624+
self.custom_object_type.save(update_fields=['cache_timestamp'])
1625+
15971626
super().save(*args, **kwargs)
15981627

15991628
# Ensure FK constraints AFTER the transaction commits to avoid "pending trigger events" errors
@@ -1622,6 +1651,9 @@ def delete(self, *args, **kwargs):
16221651
# Clear the model cache for this CustomObjectType when a field is deleted
16231652
self.custom_object_type.clear_model_cache(self.custom_object_type.id)
16241653

1654+
# Update parent's cache_timestamp to invalidate cache across all workers
1655+
self.custom_object_type.save(update_fields=['cache_timestamp'])
1656+
16251657
super().delete(*args, **kwargs)
16261658

16271659
# Reregister SearchIndex with new set of searchable fields

0 commit comments

Comments
 (0)