Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manually delete unused keys from redis activity cache #3282

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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: 2 additions & 0 deletions bookwyrm/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
# timeout for a query to an individual connector
QUERY_TIMEOUT = env.int("INTERACTIVE_QUERY_TIMEOUT", env.int("QUERY_TIMEOUT", 5))

CACHE_KEY_PREFIX = "django_cache"
# Redis cache backend
if env.bool("USE_DUMMY_CACHE", False):
CACHES = {
Expand All @@ -259,6 +260,7 @@
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": REDIS_ACTIVITY_URL,
"KEY_PREFIX": CACHE_KEY_PREFIX,
},
"file_resubmit": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
Expand Down
4 changes: 4 additions & 0 deletions bookwyrm/templates/settings/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ <h2 class="menu-label">{% trans "System" %}</h2>
{% url 'settings-celery' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Celery status" %}</a>
</li>
<li>
{% url 'settings-redis' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Redis status" %}</a>
</li>
<li>
{% url 'settings-schedules' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Scheduled tasks" %}</a>
Expand Down
125 changes: 125 additions & 0 deletions bookwyrm/templates/settings/redis.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
{% extends 'settings/layout.html' %}
{% load humanize %}
{% load i18n %}

{% block title %}{% trans "Redis Status" %}{% endblock %}

{% block header %}{% trans "Redis Status" %}{% endblock %}

{% block panel %}

{% if info %}
<section class="block content">
<h2>{% trans "Info" %}</h2>
<div class="columns has-text-centered is-multiline">
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Used memory" %}</p>
<p class="title is-5">{{ info.used_memory_human }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Total system memory" %}</p>
<p class="title is-5">{{ info.total_system_memory_human }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Keys" %}</p>
<p class="title is-5">{{ info.db0.keys | intcomma }}</p>
</div>
</div>
</div>
</section>

<section class="block content">
<h2>{% trans "Outdated cache keys" %}</h2>
<div class="box">
<p>
{% blocktrans trimmed %}
This will scan for keys in the Django redis cache that use no prefix (the current prefix is <code>{{ prefix }}</code>), and identify Activity Streams for users with deleted accounts.
{% endblocktrans %}
</p>

{% if outdated_identified is not None %}
<p class="notification has-text-weight-bold">
{% blocktrans trimmed with keys=outdated_identified|intcomma %}
{{ keys }} identified
{% endblocktrans %}
</p>

{% if outdated_identified > 0 %}
<form name="erase-keys" method="POST" action="{% url 'settings-redis' %}">
{% csrf_token %}
<button type="submit" class="button is-danger">{% trans "Erase outdated keys" %}</button>
</form>
{% endif %}
{% else %}
<form name="scan-keys" method="POST" action="{% url 'settings-redis' %}">
{% csrf_token %}
<input type="hidden" name="dry_run" value="True">
<button type="submit" class="button">
{% trans "Scan for outdated keys" %}
</button>
</form>
{% endif %}
</div>
</section>

<section class="block content">
<h2>{% trans "Advanced" %}</h2>
<details class="details-panel box">
<summary>
<span class="title is-5" role="headind" aria-level="3">
{% trans "Clear Django Cache" %}
</span>
<span class="details-close icon icon-x" aria-hidden="true"></span>
</summary>

<p>
{% blocktrans trimmed %}
This is <strong>NOT recommended</strong> and should only be used if something has gone very wrong with your cache. All sessions will be cleared and users will be logged out of their accounts.
{% endblocktrans %}
</p>

{% if cache_deleted %}
<p class="notification has-text-weight-bold">
{% blocktrans trimmed with keys=cache_deleted|intcomma %}
{{ keys }} keys deleted
{% endblocktrans %}
</p>
{% else %}
<form name="erase-cache" method="POST" action="{% url 'settings-redis' %}">
{% csrf_token %}
<input type="hidden" name="erase_cache" value="True">
<div class="control">
<button type="submit" class="button is-danger">{% trans "Erase cache" %}</button>
</div>
</form>
{% endif %}
</details>
</section>

{% else %}

<div class="notification is-danger is-flex is-align-items-start">
<span class="icon icon-warning is-size-4 pr-3" aria-hidden="true"></span>
<span>
{% trans "Could not connect to Redis Activity" %}
</span>
</div>

{% endif %}

{% if errors %}
<div class="block content">
<h2>{% trans "Errors" %}</h2>
{% for error in errors %}
<pre>{{ error }}</pre>
{% endfor %}

</div>
{% endif %}

{% endblock %}
93 changes: 93 additions & 0 deletions bookwyrm/tests/views/admin/test_redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
""" test for app action functionality """
from unittest.mock import patch

from django.contrib.auth.models import Group
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory

from bookwyrm import models, views
from bookwyrm.management.commands import initdb
from bookwyrm.tests.validate_html import validate_html


class RedisStatusViews(TestCase):
"""every response to a get request, html or json"""

@classmethod
def setUpTestData(cls):
"""we need basic test data and mocks"""
with (
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
):
cls.local_user = models.User.objects.create_user(
"[email protected]",
"[email protected]",
"password",
local=True,
localname="mouse",
)
initdb.init_groups()
initdb.init_permissions()
group = Group.objects.get(name="admin")
cls.local_user.groups.set([group])
models.SiteSettings.objects.create()

def setUp(self):
"""individual test setup"""
self.factory = RequestFactory()

def test_redis_status_get(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.RedisStatus.as_view()
request = self.factory.get("")
request.user = self.local_user

result = view(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)

def test_redis_status_post_scan_keys(self):
"""count keys in redis"""
view = views.RedisStatus.as_view()
request = self.factory.post("", {"dry_run": True})
request.user = self.local_user

result = view(request)

Check failure on line 59 in bookwyrm/tests/views/admin/test_redis.py

View workflow job for this annotation

GitHub Actions / Tests (pytest)

RedisStatusViews.test_redis_status_post_scan_keys redis.exceptions.ConnectionError: Error -3 connecting to redis_activity:6379. Temporary failure in name resolution.
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertTrue("outdated_identified" in result.context_data)
self.assertFalse("outdated_deleted" in result.context_data)
self.assertFalse("cache_deleted" in result.context_data)

def test_redis_status_post_erase_keys(self):
"""count keys in redis"""
view = views.RedisStatus.as_view()
request = self.factory.post("")
request.user = self.local_user

result = view(request)

Check failure on line 73 in bookwyrm/tests/views/admin/test_redis.py

View workflow job for this annotation

GitHub Actions / Tests (pytest)

RedisStatusViews.test_redis_status_post_erase_keys redis.exceptions.ConnectionError: Error -3 connecting to redis_activity:6379. Temporary failure in name resolution.
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertFalse("outdated_identified" in result.context_data)
self.assertTrue("outdated_deleted" in result.context_data)
self.assertFalse("cache_deleted" in result.context_data)

def test_redis_status_post_erase_cache(self):
"""count keys in redis"""
view = views.RedisStatus.as_view()
request = self.factory.post("", {"erase_cache": True})
request.user = self.local_user

result = view(request)

Check failure on line 87 in bookwyrm/tests/views/admin/test_redis.py

View workflow job for this annotation

GitHub Actions / Tests (pytest)

RedisStatusViews.test_redis_status_post_erase_cache redis.exceptions.ConnectionError: Error -3 connecting to redis_activity:6379. Temporary failure in name resolution.
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
self.assertFalse("outdated_identified" in result.context_data)
self.assertFalse("outdated_deleted" in result.context_data)
self.assertTrue("cache_deleted" in result.context_data)
1 change: 1 addition & 0 deletions bookwyrm/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@
re_path(
r"^settings/celery/ping/?$", views.celery_ping, name="settings-celery-ping"
),
re_path(r"^settings/redis/?$", views.RedisStatus.as_view(), name="settings-redis"),
re_path(
r"^settings/schedules/(?P<task_id>\d+)?$",
views.ScheduledTasks.as_view(),
Expand Down
1 change: 1 addition & 0 deletions bookwyrm/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .admin.automod import AutoMod, automod_delete, run_automod
from .admin.automod import schedule_automod_task, unschedule_automod_task
from .admin.celery_status import CeleryStatus, celery_ping
from .admin.redis import RedisStatus
from .admin.schedule import ScheduledTasks
from .admin.dashboard import Dashboard
from .admin.federation import Federation, FederatedServer
Expand Down
76 changes: 76 additions & 0 deletions bookwyrm/views/admin/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
""" redis cache status """
from django.contrib.auth.decorators import login_required, permission_required
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
import redis

from bookwyrm import models, settings

r = redis.from_url(settings.REDIS_ACTIVITY_URL)

# pylint: disable= no-self-use
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_instance_settings", raise_exception=True),
name="dispatch",
)
class RedisStatus(View):
"""Are your tasks running? Well you'd better go catch them"""

def get(self, request):
"""See workers and active tasks"""
data = view_data()

return TemplateResponse(request, "settings/redis.html", data)

def post(self, request):
"""Erase invalid keys"""
dry_run = request.POST.get("dry_run")
erase_cache = request.POST.get("erase_cache")
data_key = "cache" if erase_cache else "outdated"

if erase_cache:
patterns = [f"{settings.CACHE_KEY_PREFIX}:*:*"]
else:
patterns = [":*:*"] # this pattern is a django cache with no prefix
for user_id in models.User.objects.filter(
is_deleted=True, local=True
).values_list("id", flat=True):
patterns.append(f"{user_id}-*")

deleted_count = 0
for pattern in patterns:
deleted_count += erase_keys(pattern, dry_run=dry_run)

data = view_data()
if dry_run:
data[f"{data_key}_identified"] = deleted_count
else:
data[f"{data_key}_deleted"] = deleted_count
return TemplateResponse(request, "settings/redis.html", data)


def view_data():
"""Helper function to load basic info for the view"""
data = {"errors": [], "prefix": settings.CACHE_KEY_PREFIX}
try:
data["info"] = r.info()
# pylint: disable=broad-except
except Exception as err:
data["errors"].append(err)
return data


def erase_keys(pattern, count=1000, dry_run=False):
"""Delete all redis activity keys according to a provided regex pattern"""
pipeline = r.pipeline()
key_count = 0
for key in r.scan_iter(match=pattern, count=count):
key_count += 1
if dry_run:
continue
pipeline.delete(key)
if not dry_run:
pipeline.execute()
return key_count
Loading