Skip to content

Commit c66655f

Browse files
feat: debounce rfc index refresh (ietf-tools#10708)
* feat: DirtyBits model + admin * style: ruff + modernize utils/admin.py * chore: remove unused code * feat: rfcindex DirtyBits helpers * feat: refresh RFC index only when dirty Renames the task to better match its purpose * test: update test * fix: typo * chore: add (empty) DirtyBits resource * fix: actually call mark_rfcindex_as_processed
1 parent 1689b88 commit c66655f

8 files changed

Lines changed: 183 additions & 95 deletions

File tree

ietf/api/tests_views_rpc.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright The IETF Trust 2025, All Rights Reserved
2+
import datetime
23
from io import StringIO
34
from pathlib import Path
45
from tempfile import TemporaryDirectory
@@ -10,12 +11,15 @@
1011
from django.test.utils import override_settings
1112
from django.urls import reverse as urlreverse
1213
import mock
14+
from django.utils import timezone
1315

1416
from ietf.blobdb.models import Blob
1517
from ietf.doc.factories import IndividualDraftFactory, RfcFactory, WgDraftFactory, WgRfcFactory
1618
from ietf.doc.models import RelatedDocument, Document
1719
from ietf.group.factories import RoleFactory, GroupFactory
1820
from ietf.person.factories import PersonFactory
21+
from ietf.sync.rfcindex import rfcindex_is_dirty
22+
from ietf.utils.models import DirtyBits
1923
from ietf.utils.test_utils import APITestCase, reload_db_objects
2024

2125

@@ -408,16 +412,21 @@ def _valid_post_data():
408412
)
409413

410414
@override_settings(APP_API_TOKENS={"ietf.api.views_rpc": ["valid-token"]})
411-
@mock.patch("ietf.api.views_rpc.create_rfc_index_task")
412-
def test_refresh_rfc_index(self, mock_task):
415+
def test_refresh_rfc_index(self):
416+
DirtyBits.objects.create(
417+
slug=DirtyBits.Slugs.RFCINDEX,
418+
dirty_time=timezone.now() - datetime.timedelta(days=1),
419+
processed_time=timezone.now() - datetime.timedelta(hours=12),
420+
)
421+
self.assertFalse(rfcindex_is_dirty())
413422
url = urlreverse("ietf.api.purple_api.refresh_rfc_index")
414423
response = self.client.get(url)
415424
self.assertEqual(response.status_code, 403)
416425
response = self.client.get(url, headers={"X-Api-Key": "invalid-token"})
417426
self.assertEqual(response.status_code, 403)
418427
response = self.client.get(url, headers={"X-Api-Key": "valid-token"})
419428
self.assertEqual(response.status_code, 405)
420-
self.assertFalse(mock_task.delay.called)
429+
self.assertFalse(rfcindex_is_dirty())
421430
response = self.client.post(url, headers={"X-Api-Key": "valid-token"})
422431
self.assertEqual(response.status_code, 202)
423-
self.assertTrue(mock_task.delay.called)
432+
self.assertTrue(rfcindex_is_dirty())

ietf/api/views_rpc.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
update_rfc_searchindex_task,
4848
)
4949
from ietf.person.models import Email, Person
50-
from ietf.sync.tasks import create_rfc_index_task
50+
from ietf.sync.rfcindex import mark_rfcindex_as_dirty
5151

5252

5353
class Conflict(APIException):
@@ -548,5 +548,5 @@ class RfcIndexView(APIView):
548548
request=None,
549549
)
550550
def post(self, request):
551-
create_rfc_index_task.delay()
551+
mark_rfcindex_as_dirty()
552552
return Response(status=202)

ietf/sync/rfcindex.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright The IETF Trust 2026, All Rights Reserved
2+
import datetime
23
import json
34
from collections import defaultdict
45
from collections.abc import Container
@@ -11,6 +12,7 @@
1112

1213
from django.conf import settings
1314
from django.core.files.base import ContentFile
15+
from django.db.models import Q
1416
from lxml import etree
1517

1618
from django.core.files.storage import storages
@@ -22,6 +24,7 @@
2224
from ietf.doc.models import Document
2325
from ietf.name.models import StdLevelName
2426
from ietf.utils.log import log
27+
from ietf.utils.models import DirtyBits
2528

2629
FORMATS_FOR_INDEX = ["txt", "html", "pdf", "xml", "ps"]
2730
SS_TXT_MARGIN = 3
@@ -739,3 +742,50 @@ def create_fyi_txt_index():
739742
},
740743
)
741744
save_to_red_bucket("fyi-index.txt", index)
745+
746+
747+
## DirtyBits management for the RFC index
748+
749+
RFCINDEX_SLUG = DirtyBits.Slugs.RFCINDEX
750+
751+
752+
def mark_rfcindex_as_dirty():
753+
_, created = DirtyBits.objects.update_or_create(
754+
slug=RFCINDEX_SLUG, defaults={"dirty_time": timezone.now()}
755+
)
756+
if created:
757+
log(f"Created DirtyBits(slug='{RFCINDEX_SLUG}')")
758+
759+
760+
def mark_rfcindex_as_processed(when: datetime.datetime):
761+
n_updated = DirtyBits.objects.filter(
762+
Q(processed_time__isnull=True) | Q(processed_time__lt=when),
763+
slug=RFCINDEX_SLUG,
764+
).update(processed_time=when)
765+
if n_updated > 0:
766+
log(f"processed_time is now {when.isoformat()}")
767+
else:
768+
log("processed_time not updated, no matching record found")
769+
770+
771+
def rfcindex_is_dirty():
772+
"""Does the rfc index need to be updated?"""
773+
dirty_work, created = DirtyBits.objects.get_or_create(
774+
slug=RFCINDEX_SLUG, defaults={"dirty_time": timezone.now()}
775+
)
776+
if created:
777+
log(f"Created DirtyBits(slug='{RFCINDEX_SLUG}')")
778+
display_processed_time = (
779+
dirty_work.processed_time.isoformat()
780+
if dirty_work.processed_time is not None
781+
else "never"
782+
)
783+
log(
784+
f"DirtyBits(slug='{RFCINDEX_SLUG}'): "
785+
f"dirty_time={dirty_work.dirty_time.isoformat()} "
786+
f"processed_time={display_processed_time}"
787+
)
788+
return (
789+
dirty_work.processed_time is None
790+
or dirty_work.dirty_time >= dirty_work.processed_time
791+
)

ietf/sync/tasks.py

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
create_rfc_txt_index,
2525
create_rfc_xml_index,
2626
create_std_txt_index,
27+
rfcindex_is_dirty, mark_rfcindex_as_processed,
2728
)
2829
from ietf.sync.utils import build_from_file_content, load_rfcs_into_blobdb, rsync_helper
2930
from ietf.utils import log
@@ -288,33 +289,40 @@ def load_rfcs_into_blobdb_task(start: int, end: int):
288289

289290

290291
@shared_task
291-
def create_rfc_index_task():
292-
try:
293-
create_rfc_txt_index()
294-
except Exception as e:
295-
log.log(f"Error: failure in creating rfc-index.txt. {e}")
296-
pass
297-
298-
try:
299-
create_rfc_xml_index()
300-
except Exception as e:
301-
log.log(f"Error: failure in creating rfc-index.xml. {e}")
302-
pass
303-
304-
try:
305-
create_bcp_txt_index()
306-
except Exception as e:
307-
log.log(f"Error: failure in creating bcp-index.txt. {e}")
308-
pass
309-
310-
try:
311-
create_std_txt_index()
312-
except Exception as e:
313-
log.log(f"Error: failure in creating std-index.txt. {e}")
314-
pass
315-
316-
try:
317-
create_fyi_txt_index()
318-
except Exception as e:
319-
log.log(f"Error: failure in creating fyi-index.txt. {e}")
320-
pass
292+
def refresh_rfc_index_task():
293+
if rfcindex_is_dirty():
294+
# new_processed_time is the *start* of processing so that any changes after
295+
# this point will trigger another refresh
296+
new_processed_time = timezone.now()
297+
298+
try:
299+
create_rfc_txt_index()
300+
except Exception as e:
301+
log.log(f"Error: failure in creating rfc-index.txt. {e}")
302+
pass
303+
304+
try:
305+
create_rfc_xml_index()
306+
except Exception as e:
307+
log.log(f"Error: failure in creating rfc-index.xml. {e}")
308+
pass
309+
310+
try:
311+
create_bcp_txt_index()
312+
except Exception as e:
313+
log.log(f"Error: failure in creating bcp-index.txt. {e}")
314+
pass
315+
316+
try:
317+
create_std_txt_index()
318+
except Exception as e:
319+
log.log(f"Error: failure in creating std-index.txt. {e}")
320+
pass
321+
322+
try:
323+
create_fyi_txt_index()
324+
except Exception as e:
325+
log.log(f"Error: failure in creating fyi-index.txt. {e}")
326+
pass
327+
328+
mark_rfcindex_as_processed(new_processed_time)

ietf/utils/admin.py

Lines changed: 12 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,30 @@
1-
# Copyright The IETF Trust 2011-2020, All Rights Reserved
2-
# -*- coding: utf-8 -*-
1+
# Copyright The IETF Trust 2011-2026, All Rights Reserved
32

43

54
from django.contrib import admin
6-
from django.utils.encoding import force_str
7-
8-
def name(obj):
9-
if hasattr(obj, 'abbrev'):
10-
return obj.abbrev()
11-
elif hasattr(obj, 'name'):
12-
if callable(obj.name):
13-
name = obj.name()
14-
else:
15-
name = force_str(obj.name)
16-
if name:
17-
return name
18-
return str(obj)
19-
20-
def admin_link(field, label=None, ordering="", display=name, suffix=""):
21-
if not label:
22-
label = field.capitalize().replace("_", " ").strip()
23-
if ordering == "":
24-
ordering = field
25-
def _link(self):
26-
obj = self
27-
for attr in field.split("__"):
28-
obj = getattr(obj, attr)
29-
if callable(obj):
30-
obj = obj()
31-
if hasattr(obj, "all"):
32-
objects = obj.all()
33-
elif callable(obj):
34-
objects = obj()
35-
if not hasattr(objects, "__iter__"):
36-
objects = [ objects ]
37-
elif hasattr(obj, "__iter__"):
38-
objects = obj
39-
else:
40-
objects = [ obj ]
41-
chunks = []
42-
for obj in objects:
43-
app = obj._meta.app_label
44-
model = obj.__class__.__name__.lower()
45-
id = obj.pk
46-
chunks += [ '<a href="/admin/%(app)s/%(model)s/%(id)s/%(suffix)s">%(display)s</a>' %
47-
{'app':app, "model": model, "id":id, "display": display(obj), "suffix":suffix, } ]
48-
return ", ".join(chunks)
49-
_link.allow_tags = True
50-
_link.short_description = label
51-
_link.admin_order_field = ordering
52-
return _link
5+
from .models import DumpInfo, DirtyBits
536

547

558
class SaferStackedInline(admin.StackedInline):
569
"""StackedInline without delete by default"""
10+
5711
can_delete = False # no delete button
5812
show_change_link = True # show a link to the resource (where it can be deleted)
5913

6014

6115
class SaferTabularInline(admin.TabularInline):
6216
"""TabularInline without delete by default"""
17+
6318
can_delete = False # no delete button
6419
show_change_link = True # show a link to the resource (where it can be deleted)
6520

6621

67-
from .models import DumpInfo
22+
@admin.register(DumpInfo)
6823
class DumpInfoAdmin(admin.ModelAdmin):
69-
list_display = ['date', 'host', 'tz']
70-
list_filter = ['date']
71-
admin.site.register(DumpInfo, DumpInfoAdmin)
24+
list_display = ["date", "host", "tz"]
25+
list_filter = ["date"]
26+
27+
28+
@admin.register(DirtyBits)
29+
class DirtyBitsAdmin(admin.ModelAdmin):
30+
list_display = ["slug", "dirty_time", "processed_time"]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright The IETF Trust 2026, All Rights Reserved
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("utils", "0002_delete_versioninfo"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="DirtyBits",
14+
fields=[
15+
(
16+
"id",
17+
models.AutoField(
18+
auto_created=True,
19+
primary_key=True,
20+
serialize=False,
21+
verbose_name="ID",
22+
),
23+
),
24+
(
25+
"slug",
26+
models.CharField(
27+
choices=[("rfcindex", "RFC Index")], max_length=40, unique=True
28+
),
29+
),
30+
("dirty_time", models.DateTimeField(blank=True, null=True)),
31+
("processed_time", models.DateTimeField(blank=True, null=True)),
32+
],
33+
options={
34+
"verbose_name_plural": "dirty bits",
35+
},
36+
),
37+
]

ietf/utils/models.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,35 @@
1-
# Copyright The IETF Trust 2015-2020, All Rights Reserved
1+
# Copyright The IETF Trust 2015-2026, All Rights Reserved
22

33
import itertools
44

55
from django.db import models
66

7+
8+
class DirtyBits(models.Model):
9+
"""A weak semaphore mechanism for coordination with celery beat tasks
10+
11+
Web workers will set the "dirty_time" value for a given dirtybit slug.
12+
Celery workers will do work if "processed_time" < "dirty_time" and update
13+
"processed_time".
14+
"""
15+
16+
class Slugs(models.TextChoices):
17+
RFCINDEX = "rfcindex", "RFC Index"
18+
19+
# next line can become `...choices=Slugs)` when we get to Django 5.x
20+
slug = models.CharField(max_length=40, blank=False, choices=Slugs.choices, unique=True)
21+
dirty_time = models.DateTimeField(null=True, blank=True)
22+
processed_time = models.DateTimeField(null=True, blank=True)
23+
24+
class Meta:
25+
verbose_name_plural = "dirty bits"
26+
27+
728
class DumpInfo(models.Model):
829
date = models.DateTimeField()
930
host = models.CharField(max_length=128)
1031
tz = models.CharField(max_length=32, default='UTC')
11-
32+
1233
class ForeignKey(models.ForeignKey):
1334
"A local ForeignKey proxy which provides the on_delete value required under Django 2.0."
1435
def __init__(self, to, on_delete=models.CASCADE, **kwargs):

0 commit comments

Comments
 (0)