From d6eb16a398a8d3a2f58399fd40df7f212680cab0 Mon Sep 17 00:00:00 2001 From: Michael Manfre Date: Mon, 5 Dec 2022 12:55:30 -0500 Subject: [PATCH] Added caching and initial settings --- activities/views/follows.py | 4 +++ activities/views/posts.py | 2 ++ activities/views/timelines.py | 14 +++++++++ core/decorators.py | 41 ++++++++++++++++++++++++++ core/middleware.py | 12 +++++++- core/models/config.py | 5 ++++ core/views.py | 3 ++ docs/tuning.rst | 22 ++++++++++++++ requirements.txt | 1 + takahe/settings.py | 11 +++++++ takahe/urls.py | 5 ++++ templates/settings/_menu.html | 3 ++ users/views/activitypub.py | 4 +++ users/views/admin/__init__.py | 2 +- users/views/admin/settings.py | 49 +++++++++++++++++++++++++++++++ users/views/identity.py | 5 ++++ users/views/settings/interface.py | 6 +++- 17 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 core/decorators.py diff --git a/activities/views/follows.py b/activities/views/follows.py index 44d8adc..841c8cc 100644 --- a/activities/views/follows.py +++ b/activities/views/follows.py @@ -1,11 +1,15 @@ from django.utils.decorators import method_decorator from django.views.generic import TemplateView +from core.decorators import per_identity_cache_page from users.decorators import identity_required from users.models import FollowStates @method_decorator(identity_required, name="dispatch") +@method_decorator( + per_identity_cache_page("cache_timeout_page_timeline"), name="dispatch" +) class FollowsPage(TemplateView): """ Shows followers/follows. diff --git a/activities/views/posts.py b/activities/views/posts.py index ef16713..60873a7 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -6,11 +6,13 @@ from django.utils.decorators import method_decorator from django.views.generic import TemplateView, View from activities.models import Post, PostInteraction, PostInteractionStates, PostStates +from core.decorators import per_identity_cache_page from core.ld import canonicalise from users.decorators import identity_required from users.shortcuts import by_handle_or_404 +@method_decorator(per_identity_cache_page("cache_timeout_page_post"), name="dispatch") class Individual(TemplateView): template_name = "activities/post.html" diff --git a/activities/views/timelines.py b/activities/views/timelines.py index 753add6..156a20d 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -5,11 +5,13 @@ from django.utils.decorators import method_decorator from django.views.generic import FormView, ListView from activities.models import Hashtag, Post, PostInteraction, TimelineEvent +from core.decorators import per_identity_cache_page from core.models import Config from users.decorators import identity_required @method_decorator(identity_required, name="dispatch") +@method_decorator(per_identity_cache_page(), name="dispatch") class Home(FormView): template_name = "activities/home.html" @@ -61,6 +63,9 @@ class Home(FormView): return redirect(".") +@method_decorator( + per_identity_cache_page("cache_timeout_page_timeline"), name="dispatch" +) class Tag(ListView): template_name = "activities/tag.html" @@ -96,6 +101,9 @@ class Tag(ListView): return context +@method_decorator( + per_identity_cache_page("cache_timeout_page_timeline"), name="dispatch" +) class Local(ListView): template_name = "activities/local.html" @@ -122,6 +130,9 @@ class Local(ListView): @method_decorator(identity_required, name="dispatch") +@method_decorator( + per_identity_cache_page("cache_timeout_page_timeline"), name="dispatch" +) class Federated(ListView): template_name = "activities/federated.html" @@ -150,6 +161,9 @@ class Federated(ListView): @method_decorator(identity_required, name="dispatch") +@method_decorator( + per_identity_cache_page("cache_timeout_page_timeline"), name="dispatch" +) class Notifications(ListView): template_name = "activities/notifications.html" diff --git a/core/decorators.py b/core/decorators.py new file mode 100644 index 0000000..4934b60 --- /dev/null +++ b/core/decorators.py @@ -0,0 +1,41 @@ +from functools import partial, wraps + +from django.views.decorators.cache import cache_page as dj_cache_page + +from core.models import Config + + +def cache_page( + timeout: int | str = "cache_timeout_page_default", + *, + per_identity: bool = False, + key_prefix: str = "", +): + """ + Decorator for views that caches the page result. + timeout can either be the number of seconds or the name of a SystemOptions + value. + """ + if isinstance(timeout, str): + timeout = Config.lazy_system_value(timeout) + + def decorator(function): + @wraps(function) + def inner(request, *args, **kwargs): + prefix = key_prefix + if per_identity: + identity_id = request.identity.pk if request.identity else "0" + prefix = f"{key_prefix or ''}:ident{identity_id}" + _timeout = timeout + if callable(_timeout): + _timeout = _timeout() + return dj_cache_page(timeout=_timeout, key_prefix=prefix)(function)( + request, *args, **kwargs + ) + + return inner + + return decorator + + +per_identity_cache_page = partial(cache_page, per_identity=True) diff --git a/core/middleware.py b/core/middleware.py index 08c28fa..bd89d1c 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -1,3 +1,5 @@ +from time import time + from django.core.exceptions import MiddlewareNotUsed from core import sentry @@ -9,11 +11,19 @@ class ConfigLoadingMiddleware: Caches the system config every request """ + refresh_interval: float = 30.0 + def __init__(self, get_response): self.get_response = get_response + self.config_ts: float = 0.0 def __call__(self, request): - Config.system = Config.load_system() + if ( + not getattr(Config, "system", None) + or (time() - self.config_ts) >= self.refresh_interval + ): + Config.system = Config.load_system() + self.config_ts = time() response = self.get_response(request) return response diff --git a/core/models/config.py b/core/models/config.py index dd7da9f..b18471e 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -218,6 +218,11 @@ class Config(models.Model): hashtag_unreviewed_are_public: bool = True hashtag_stats_max_age: int = 60 * 60 + cache_timeout_page_default: int = 60 + cache_timeout_page_timeline: int = 60 * 3 + cache_timeout_page_post: int = 60 * 2 + cache_timeout_identity_feed: int = 60 * 5 + restricted_usernames: str = "admin\nadmins\nadministrator\nadministrators\nsystem\nroot\nannounce\nannouncement\nannouncements" class UserOptions(pydantic.BaseModel): diff --git a/core/views.py b/core/views.py index 889fa27..ea8a1ca 100644 --- a/core/views.py +++ b/core/views.py @@ -1,8 +1,10 @@ from django.http import JsonResponse from django.templatetags.static import static +from django.utils.decorators import method_decorator from django.views.generic import TemplateView, View from activities.views.timelines import Home +from core.decorators import cache_page from users.models import Identity @@ -13,6 +15,7 @@ def homepage(request): return LoggedOutHomepage.as_view()(request) +@method_decorator(cache_page(), name="dispatch") class LoggedOutHomepage(TemplateView): template_name = "index.html" diff --git a/docs/tuning.rst b/docs/tuning.rst index 2b5e5d8..6f7badc 100644 --- a/docs/tuning.rst +++ b/docs/tuning.rst @@ -14,3 +14,25 @@ Environment Variable: making remote requests to other Fediverse instances. This may also be a tuple of four floats to set the timeouts for connect, read, write, and pool. Example ``TAKAHE_REMOTE_TIMEOUT='[0.5, 1.0, 1.0, 0.5]'`` + + +Caching +-------- + +By default Takakē has caching disabled. The caching needs of a server can +varying drastically based upon the number of users and how interconnected +they are with other servers. + +Caching is configured by specifying a cache DSN in the environment variable +``TAKAHE_CACHES_DEFAULT``. The DSN format can be any supported by +`django-cache-url `_, but +some cache backends will require additional Python pacakages not required +by Takahē. + +**Examples** + +* LocMem cache for a small server: ``locmem://default`` +* Memcache cache for a service named ``memcache`` in a docker compose file: + ``memcached://memcache:11211?key_prefix=takahe`` +* Multiple memcache cache servers: + ``memcached://server1:11211,server2:11211`` diff --git a/requirements.txt b/requirements.txt index 89c1bd3..9f3e3ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ bleach~=5.0.1 blurhash-python~=1.1.3 cryptography~=38.0 dj_database_url~=1.0.0 +django-cache-url~=3.4.2 django-htmx~=1.13.0 django-storages[google,boto3]~=1.13.1 django~=4.1 diff --git a/takahe/settings.py b/takahe/settings.py index 7952024..9769e98 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Literal import dj_database_url +import django_cache_url import sentry_sdk from pydantic import AnyUrl, BaseSettings, EmailStr, Field, validator from sentry_sdk.integrations.django import DjangoIntegration @@ -15,6 +16,11 @@ from takahe import __version__ BASE_DIR = Path(__file__).resolve().parent.parent +class CacheBackendUrl(AnyUrl): + host_required = False + allowed_schemes = django_cache_url.BACKENDS.keys() + + class ImplicitHostname(AnyUrl): host_required = False @@ -107,6 +113,9 @@ class Settings(BaseSettings): #: (placeholder setting, no effect) SEARCH: bool = True + #: Default cache backend + CACHES_DEFAULT: CacheBackendUrl | None = None + PGHOST: str | None = None PGPORT: int | None = 5432 PGNAME: str = "takahe" @@ -339,5 +348,7 @@ if SETUP.MEDIA_BACKEND: else: raise ValueError(f"Unsupported media backend {parsed.scheme}") +CACHES = {"default": django_cache_url.parse(SETUP.CACHES_DEFAULT or "dummy://")} + if SETUP.ERROR_EMAILS: ADMINS = [("Admin", e) for e in SETUP.ERROR_EMAILS] diff --git a/takahe/urls.py b/takahe/urls.py index 59f14ba..a6e8a6e 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -55,6 +55,11 @@ urlpatterns = [ admin.BasicSettings.as_view(), name="admin_basic", ), + path( + "admin/tuning/", + admin.TuningSettings.as_view(), + name="admin_tuning", + ), path( "admin/domains/", admin.Domains.as_view(), diff --git a/templates/settings/_menu.html b/templates/settings/_menu.html index 55c0100..fa2e74e 100644 --- a/templates/settings/_menu.html +++ b/templates/settings/_menu.html @@ -36,6 +36,9 @@ Hashtags + + Tuning + Django Admin diff --git a/users/views/activitypub.py b/users/views/activitypub.py index cca57fb..c3395a4 100644 --- a/users/views/activitypub.py +++ b/users/views/activitypub.py @@ -9,6 +9,7 @@ from django.views.generic import View from activities.models import Post from core import exceptions +from core.decorators import cache_page from core.ld import canonicalise from core.models import Config from core.signatures import ( @@ -61,6 +62,7 @@ class NodeInfo(View): ) +@method_decorator(cache_page(), name="dispatch") class NodeInfo2(View): """ Returns the nodeinfo 2.0 response @@ -87,6 +89,7 @@ class NodeInfo2(View): ) +@method_decorator(cache_page(), name="dispatch") class Webfinger(View): """ Services webfinger requests @@ -189,6 +192,7 @@ class Inbox(View): return HttpResponse(status=202) +@method_decorator(cache_page(), name="dispatch") class SystemActorView(View): """ Special endpoint for the overall system actor diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py index 101ca30..04e1195 100644 --- a/users/views/admin/__init__.py +++ b/users/views/admin/__init__.py @@ -17,7 +17,7 @@ from users.views.admin.hashtags import ( # noqa HashtagEdit, Hashtags, ) -from users.views.admin.settings import BasicSettings # noqa +from users.views.admin.settings import BasicSettings, TuningSettings # noqa @method_decorator(admin_required, name="dispatch") diff --git a/users/views/admin/settings.py b/users/views/admin/settings.py index a9ec78b..dc56693 100644 --- a/users/views/admin/settings.py +++ b/users/views/admin/settings.py @@ -1,4 +1,5 @@ from django.utils.decorators import method_decorator +from django.utils.safestring import mark_safe from core.models import Config from users.decorators import admin_required @@ -106,3 +107,51 @@ class BasicSettings(AdminSettingsPage): "restricted_usernames", ], } + + +cache_field_defaults = { + "min_value": 0, + "max_value": 900, + "step_size": 15, +} + + +class TuningSettings(AdminSettingsPage): + + section = "tuning" + + options = { + "cache_timeout_page_default": { + **cache_field_defaults, + "title": "Default Timeout", + "help_text": "The number of seconds to cache a rendered page", + }, + "cache_timeout_page_timeline": { + **cache_field_defaults, + "title": "Timeline Timeout", + "help_text": "The number of seconds to cache a rendered timeline page", + }, + "cache_timeout_page_post": { + **cache_field_defaults, + "title": "Individual Post Timeout", + "help_text": mark_safe( + "The number of seconds to cache a rendered individual Post page
Note: This includes the JSON responses to other servers" + ), + }, + "cache_timeout_identity_feed": { + **cache_field_defaults, + "title": "Identity Feed Timeout", + "help_text": "The number of seconds to cache a rendered Identity RSS feed", + }, + } + + layout = { + "Rendered Page Cache": [ + "cache_timeout_page_default", + "cache_timeout_page_timeline", + "cache_timeout_page_post", + ], + "RSS Feeds": [ + "cache_timeout_identity_feed", + ], + } diff --git a/users/views/identity.py b/users/views/identity.py index 6cfcff9..d98ce9d 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -10,6 +10,7 @@ from django.utils.decorators import method_decorator from django.views.generic import FormView, ListView, TemplateView, View from activities.models import Post, PostInteraction +from core.decorators import per_identity_cache_page from core.ld import canonicalise from core.models import Config from users.decorators import identity_required @@ -17,6 +18,7 @@ from users.models import Domain, Follow, FollowStates, Identity, IdentityStates from users.shortcuts import by_handle_or_404 +@method_decorator(per_identity_cache_page(), name="dispatch") class ViewIdentity(ListView): """ Shows identity profile pages, and also acts as the Actor endpoint when @@ -90,6 +92,9 @@ class ViewIdentity(ListView): return context +@method_decorator( + per_identity_cache_page("cache_timeout_identity_feed"), name="__call__" +) class IdentityFeed(Feed): """ Serves a local user's Public posts as an RSS feed diff --git a/users/views/settings/interface.py b/users/views/settings/interface.py index 5c4f229..e8c73a6 100644 --- a/users/views/settings/interface.py +++ b/users/views/settings/interface.py @@ -21,7 +21,7 @@ class SettingsPage(FormView): options_class = Config.IdentityOptions template_name = "settings/settings.html" section: ClassVar[str] - options: dict[str, dict[str, str]] + options: dict[str, dict[str, str | int]] layout: dict[str, list[str]] def get_form_class(self): @@ -51,6 +51,10 @@ class SettingsPage(FormView): choices = details.get("choices") if choices: field_kwargs["widget"] = forms.Select(choices=choices) + for int_kwarg in {"min_value", "max_value", "step_size"}: + val = details.get(int_kwarg) + if val: + field_kwargs[int_kwarg] = val form_field = forms.IntegerField else: raise ValueError(f"Cannot render settings type {config_field.type_}")