From aa66fa1eb30e99b716171dc9c2058c3c32af42ae Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 3 Jun 2026 19:57:56 -0300 Subject: [PATCH 1/5] chore: "nginxinc" GH org is now "nginx" (#10981) --- k8s/auth.yaml | 2 +- k8s/datatracker.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/auth.yaml b/k8s/auth.yaml index 992c90557a..6e63001e02 100644 --- a/k8s/auth.yaml +++ b/k8s/auth.yaml @@ -69,7 +69,7 @@ spec: # Nginx Container # ----------------------------------------------------- - name: nginx - image: "ghcr.io/nginxinc/nginx-unprivileged:1.27" + image: "ghcr.io/nginx/nginx-unprivileged:1.30" imagePullPolicy: IfNotPresent ports: - containerPort: 8080 diff --git a/k8s/datatracker.yaml b/k8s/datatracker.yaml index 98a782f95c..af2bb6295c 100644 --- a/k8s/datatracker.yaml +++ b/k8s/datatracker.yaml @@ -69,7 +69,7 @@ spec: # Nginx Container # ----------------------------------------------------- - name: nginx - image: "ghcr.io/nginxinc/nginx-unprivileged:1.30" + image: "ghcr.io/nginx/nginx-unprivileged:1.30" imagePullPolicy: IfNotPresent ports: - containerPort: 8080 From 60c9f28dd01281ba0cad2354e8e16ddf490838e0 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 4 Jun 2026 11:54:19 -0300 Subject: [PATCH 2/5] chore: use redis for django caches (#10975) * chore: drop memcache, add redis (WIP) (#10940) * chore(dev): use redis for dev sessions Adds a redis container with a persistent volume and uses this for sessions instead of the database. Results in the same general behavior as before - logins should be persistent until the docker compose stack is torn down. * chore(deps): install redis/hiredis python pkgs * chore: remove memcached (mostly) Still keeps some config references and a custom cache backend * chore: switch to django-redis for sentinel support * chore: production redis cache config Uses sentinel. Untested. * chore: telemetry: add redis, drop memcache * chore: remove now-unused cache.py * chore: adjust imports in gunicorn.conf.py * chore: adjust redis sentinel settings * feat: size limit on redis values * style: ruff ruff * chore(dev): SizeLimitingRedisClient for dev Does not make a functional difference in most cases, but might as well exercise the class. * test: use db-backed sessions for tests * chore: typing nit * ci: remove DEPLOY_STRATEGY "strategery" * ci: remove strategy from dt manifests altogether --- dev/build/gunicorn.conf.py | 18 ++-- docker-compose.yml | 7 ++ docker/base.Dockerfile | 2 - ietf/settings.py | 203 ++++++++++++++----------------------- ietf/settings_test.py | 3 + ietf/utils/cache.py | 75 +++++++++++--- k8s/auth.yaml | 2 - k8s/datatracker.yaml | 2 - k8s/kustomization.yaml | 1 - k8s/memcached.yaml | 88 ---------------- k8s/settings_local.py | 46 +++++---- requirements.txt | 6 +- 12 files changed, 187 insertions(+), 266 deletions(-) delete mode 100644 k8s/memcached.yaml diff --git a/dev/build/gunicorn.conf.py b/dev/build/gunicorn.conf.py index 03e81eac5e..a1c572a096 100644 --- a/dev/build/gunicorn.conf.py +++ b/dev/build/gunicorn.conf.py @@ -7,10 +7,6 @@ from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.instrumentation.django import DjangoInstrumentor -from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor -from opentelemetry.instrumentation.pymemcache import PymemcacheInstrumentor -from opentelemetry.instrumentation.requests import RequestsInstrumentor # Bind all ipv4 interfaces (nginx uses loopback, but k8s health checks don't) _BIND_PORT = os.environ.get("DATATRACKER_GUNICORN_BIND_PORT", "8000") @@ -171,11 +167,15 @@ def post_fork(server, worker): trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(otlp_exporter)) # Instrumentations - if "all" in enabled_telemetry or "django" in enabled_telemetry: + if "all" in enabled_telemetry or "django" in enabled_telemetry: + from opentelemetry.instrumentation.django import DjangoInstrumentor DjangoInstrumentor().instrument() - if "all" in enabled_telemetry or "psycopg2" in enabled_telemetry: + if "all" in enabled_telemetry or "psycopg2" in enabled_telemetry: + from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor Psycopg2Instrumentor().instrument() - if "all" in enabled_telemetry or "pymemcache" in enabled_telemetry: - PymemcacheInstrumentor().instrument() - if "all" in enabled_telemetry or "requests" in enabled_telemetry: + if "all" in enabled_telemetry or "redis" in enabled_telemetry: + from opentelemetry.instrumentation.redis import RedisInstrumentor + RedisInstrumentor().instrument() + if "all" in enabled_telemetry or "requests" in enabled_telemetry: + from opentelemetry.instrumentation.requests import RequestsInstrumentor RequestsInstrumentor().instrument() diff --git a/docker-compose.yml b/docker-compose.yml index 073d04b896..f171fb261b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,12 @@ services: - .:/workspace - app-assets:/assets + redis: + image: redis:8 + command: ['redis-server', '--save', '10', '1', '--loglevel', 'warning'] + volumes: + - redis:/data + replicator: build: context: . @@ -169,3 +175,4 @@ volumes: app-assets: minio-data: blobdb-data: + redis: diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index 2501636049..7bf4263b38 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -59,12 +59,10 @@ RUN apt-get update --fix-missing && apt-get install -qy --no-install-recommends libxtst6 \ libmagic-dev \ libmariadb-dev \ - libmemcached-tools \ libyang2-tools \ locales \ make \ mariadb-client \ - memcached \ nano \ netcat-traditional \ nodejs \ diff --git a/ietf/settings.py b/ietf/settings.py index 95f2ffefd7..6cfc9103ff 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1364,137 +1364,87 @@ def skip_unreadable_post(record): if "CACHES" not in locals(): if SERVER_MODE == "production": - MEMCACHED_HOST = os.environ.get("MEMCACHED_SERVICE_HOST", "127.0.0.1") - MEMCACHED_PORT = os.environ.get("MEMCACHED_SERVICE_PORT", "11211") - CACHES = { - "default": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", - "VERSION": __version__, - "KEY_PREFIX": "ietf:dt", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "agenda": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt:agenda", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "proceedings": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt:proceedings", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "sessions": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt", - }, - "htmlized": { - "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/a/cache/datatracker/htmlized", - "OPTIONS": { - "MAX_ENTRIES": 100000, # 100,000 - }, - }, - "pdfized": { - "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/a/cache/datatracker/pdfized", - "OPTIONS": { - "MAX_ENTRIES": 100000, # 100,000 - }, - }, - "slowpages": { - "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/a/cache/datatracker/slowpages", - "OPTIONS": { - "MAX_ENTRIES": 5000, - }, - }, - "celery-results": { - "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", - "KEY_PREFIX": "ietf:celery", - }, - } - else: - CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'ietf.utils.cache.LenientMemcacheCache', - #'LOCATION': '127.0.0.1:11211', - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "VERSION": __version__, - "KEY_PREFIX": "ietf:dt", - }, - "agenda": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - # "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - # "LOCATION": "127.0.0.1:11211", - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt:agenda", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "proceedings": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - # "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - # "LOCATION": "127.0.0.1:11211", - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt:proceedings", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "sessions": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + raise RuntimeError("Must set CACHES in settings_local for production mode") + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + # "BACKEND": "django_redis.cache.RedisCache", + # "LOCATION": "redis://redis:6379/0", + # "OPTIONS": { + # "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", + # }, + # "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "VERSION": __version__, + "KEY_PREFIX": "ietf:dt", + }, + "agenda": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + # "BACKEND": "django_redis.cache.RedisCache", + # "LOCATION": "redis://redis:6379/0", + # "OPTIONS": { + # "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", + # }, + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt:agenda", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), + }, + "proceedings": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + # "BACKEND": "django_redis.cache.RedisCache", + # "LOCATION": "redis://redis:6379/0", + # "OPTIONS": { + # "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", + # }, + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt:proceedings", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), + }, + "sessions": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://redis:6379/0", + "OPTIONS": { + "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", }, - "htmlized": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "LOCATION": "/var/cache/datatracker/htmlized", - "OPTIONS": { - "MAX_ENTRIES": 1000, - }, + }, + "htmlized": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "LOCATION": "/var/cache/datatracker/htmlized", + "OPTIONS": { + "MAX_ENTRIES": 1000, }, - "pdfized": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "LOCATION": "/var/cache/datatracker/pdfized", - "OPTIONS": { - "MAX_ENTRIES": 1000, - }, + }, + "pdfized": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "LOCATION": "/var/cache/datatracker/pdfized", + "OPTIONS": { + "MAX_ENTRIES": 1000, }, - "slowpages": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "LOCATION": "/var/cache/datatracker/", - "OPTIONS": { - "MAX_ENTRIES": 5000, - }, + }, + "slowpages": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "LOCATION": "/var/cache/datatracker/", + "OPTIONS": { + "MAX_ENTRIES": 5000, }, - "celery-results": { - "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", - "LOCATION": "app:11211", - "KEY_PREFIX": "ietf:celery", + }, + "celery-results": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://redis:6379/0", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", }, - } + "KEY_PREFIX": "ietf:celery", + }, + } PUBLISH_IPR_STATES = ['posted', 'removed', 'removed_objfalse'] @@ -1509,7 +1459,6 @@ def skip_unreadable_post(record): loaders = TEMPLATES[0]['OPTIONS']['loaders'] loaders = tuple(l for e in loaders for l in (e[1] if isinstance(e, tuple) and "cached.Loader" in e[0] else (e,))) TEMPLATES[0]['OPTIONS']['loaders'] = loaders - SESSION_ENGINE = "django.contrib.sessions.backends.db" if 'SECRET_KEY' not in locals(): SECRET_KEY = 'PDwXboUq!=hPjnrtG2=ge#N$Dwy+wn@uivrugwpic8mxyPfHka' diff --git a/ietf/settings_test.py b/ietf/settings_test.py index e7ebc13eb2..883a582a28 100755 --- a/ietf/settings_test.py +++ b/ietf/settings_test.py @@ -69,6 +69,9 @@ def tempdir_with_cleanup(**kwargs): PHOTOS_DIR = os.path.join(MEDIA_ROOT, PHOTOS_DIRNAME) os.mkdir(PHOTOS_DIR) +# Use database-backed sessions for tests +SESSION_ENGINE = "django.contrib.sessions.backends.db" + # Undo any developer-dependent middleware when running the tests MIDDLEWARE = [ c for c in MIDDLEWARE if not c in DEV_MIDDLEWARE ] # pyflakes:ignore diff --git a/ietf/utils/cache.py b/ietf/utils/cache.py index 0baa56da2d..60dd2c3ae3 100644 --- a/ietf/utils/cache.py +++ b/ietf/utils/cache.py @@ -1,20 +1,67 @@ -# Copyright The IETF Trust 2023, All Rights Reserved -# -*- coding: utf-8 -*- +# Copyright The IETF Trust 2026, All Rights Reserved +from typing import Optional, Union, Any +from django.core.cache import BaseCache from django.core.cache.backends.base import DEFAULT_TIMEOUT -from django.core.cache.backends.memcached import PyMemcacheCache -from pymemcache.exceptions import MemcacheServerError +from django_redis.client import DefaultClient, SentinelClient +from redis import Redis +from redis.typing import KeyT, EncodableT -from .log import log +from ietf.utils.log import log -class LenientMemcacheCache(PyMemcacheCache): - """PyMemcacheCache backend that tolerates failed inserts due to object size""" - def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): +class EncodedValueTooBig(ValueError): + def __init__(self, *args, value_len): + super().__init__(*args) + self.value_len = value_len + + +class SizeLimitingRedisClient(DefaultClient): + """Redis DefaultClient that refuses to cache large objects + + Size applies to the literal cached value, which is _after_ serialization and + compression. + + Set size limit with MAX_ENCODED_VALUE_LEN in the OPTIONS dict. Defaults to + 1 MB. + """ + def __init__(self, server, params: dict[str, Any], backend: BaseCache) -> None: + super().__init__(server, params, backend) + self.max_encoded_value_len = self._options.get("MAX_ENCODED_VALUE_LEN", 1 << 20) + + def encode(self, value: EncodableT) -> Union[bytes, int]: + encoded = super().encode(value) + if isinstance(encoded, bytes) and len(encoded) > self.max_encoded_value_len: + raise EncodedValueTooBig(value_len=len(encoded)) + return encoded + + def set( + self, + key: KeyT, + value: EncodableT, + timeout: Optional[float] = DEFAULT_TIMEOUT, + version: Optional[int] = None, + client: Optional[Redis] = None, + nx: bool = False, + xx: bool = False, + ) -> bool: try: - super().set(key, value, timeout, version) - except MemcacheServerError as err: - if "object too large for cache" in str(err): - log(f"Memcache failed to cache large object for {key}") - else: - raise + return super().set(key, value, timeout, version, client, nx, xx) + except EncodedValueTooBig as err: + log( + f"Refused to cache large object for {key!r} " + f"({err.value_len} > {self.max_encoded_value_len} bytes)" + ) + return False + + +class SizeLimitingSentinelClient(SizeLimitingRedisClient, SentinelClient): + """Redis SentinelClient that refuses to cache large objects + + Size applies to the literal cached value, which is _after_ serialization and + compression. + + Set size limit with MAX_ENCODED_VALUE_LEN in the OPTIONS dict. Defaults to + 1 MB. + """ + pass diff --git a/k8s/auth.yaml b/k8s/auth.yaml index 6e63001e02..ef8c259933 100644 --- a/k8s/auth.yaml +++ b/k8s/auth.yaml @@ -8,8 +8,6 @@ spec: selector: matchLabels: app: auth - strategy: - type: DEPLOY_STRATEGY template: metadata: labels: diff --git a/k8s/datatracker.yaml b/k8s/datatracker.yaml index af2bb6295c..5183893bc8 100644 --- a/k8s/datatracker.yaml +++ b/k8s/datatracker.yaml @@ -8,8 +8,6 @@ spec: selector: matchLabels: app: datatracker - strategy: - type: DEPLOY_STRATEGY template: metadata: labels: diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 769cb03517..b1e278a914 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -12,6 +12,5 @@ resources: - beat.yaml - celery.yaml - datatracker.yaml - - memcached.yaml - rabbitmq.yaml - replicator.yaml diff --git a/k8s/memcached.yaml b/k8s/memcached.yaml deleted file mode 100644 index 68b732d745..0000000000 --- a/k8s/memcached.yaml +++ /dev/null @@ -1,88 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: memcached -spec: - replicas: 1 - revisionHistoryLimit: 2 - selector: - matchLabels: - app: memcached - template: - metadata: - labels: - app: memcached - spec: - securityContext: - runAsNonRoot: true - containers: - # ----------------------------------------------------- - # Memcached - # ----------------------------------------------------- - - image: "memcached:1.6-alpine" - imagePullPolicy: IfNotPresent - args: ["-m", "1024"] - name: memcached - ports: - - name: memcached - containerPort: 11211 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - # memcached image sets up uid/gid 11211 - runAsUser: 11211 - runAsGroup: 11211 - resources: - requests: - cpu: 100m - memory: 100Mi - # ----------------------------------------------------- - # Memcached Exporter for Prometheus - # ----------------------------------------------------- - - image: "quay.io/prometheus/memcached-exporter:v0.14.3" - imagePullPolicy: IfNotPresent - name: memcached-exporter - ports: - - name: metrics - containerPort: 9150 - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - readOnlyRootFilesystem: true - runAsUser: 65534 # nobody - runAsGroup: 65534 # nobody - resources: - requests: - cpu: 10m - memory: 20Mi - dnsPolicy: ClusterFirst - restartPolicy: Always - terminationGracePeriodSeconds: 30 ---- -apiVersion: v1 -kind: Service -metadata: - name: memcached - annotations: - k8s.grafana.com/scrape: "true" # this is not a bool - k8s.grafana.com/metrics.portName: "metrics" -spec: - type: ClusterIP - ports: - - port: 11211 - targetPort: memcached - protocol: TCP - name: memcached - - port: 9150 - targetPort: metrics - protocol: TCP - name: metrics - selector: - app: memcached diff --git a/k8s/settings_local.py b/k8s/settings_local.py index 20c5252ff0..560648c07f 100644 --- a/k8s/settings_local.py +++ b/k8s/settings_local.py @@ -315,15 +315,29 @@ def _multiline_to_list(s): DE_GFM_BINARY = "/usr/local/bin/de-gfm" IDSUBMIT_IDNITS_BINARY = "/usr/local/bin/idnits" -# Duplicating production cache from settings.py and using it whether we're in production mode or not -MEMCACHED_HOST = os.environ.get("DT_MEMCACHED_SERVICE_HOST", "127.0.0.1") -MEMCACHED_PORT = os.environ.get("DT_MEMCACHED_SERVICE_PORT", "11211") from ietf import __version__ +# Common config for redis caches +REDIS_SENTINEL_SERVICE = os.environ.get("DATATRACKER_REDIS_SENTINEL_HOST") +REDIS_SENTINEL_PORT = os.environ.get("DATATRACKER_REDIS_SENTINEL_PORT", "26379") +DJANGO_REDIS_CONNECTION_FACTORY = "django_redis.pool.SentinelConnectionFactory" +REDIS_CACHE_CONFIG_COMMON = { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://dt-master/0", + "OPTIONS": { + "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingSentinelClient", + "MAX_ENCODED_VALUE_LEN": int( + os.environ.get("DATATRACKER_REDIS_MAX_ENCODED_VALUE_LEN", 1 << 20) + ), + "SENTINELS": [(REDIS_SENTINEL_SERVICE, REDIS_SENTINEL_PORT)], + "SENTINEL_KWARGS": {}, + "CONNECTION_POOL_CLASS": "redis.sentinel.SentinelConnectionPool", + }, +} + CACHES = { - "default": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "default": REDIS_CACHE_CONFIG_COMMON + | { "VERSION": __version__, "KEY_PREFIX": "ietf:dt", # Key function is default except with sha384-encoded key @@ -331,9 +345,8 @@ def _multiline_to_list(s): f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" ), }, - "agenda": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "agenda": REDIS_CACHE_CONFIG_COMMON + | { # No release-specific VERSION setting. "KEY_PREFIX": "ietf:dt:agenda", # Key function is default except with sha384-encoded key @@ -341,9 +354,8 @@ def _multiline_to_list(s): f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" ), }, - "proceedings": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "proceedings": REDIS_CACHE_CONFIG_COMMON + | { # No release-specific VERSION setting. "KEY_PREFIX": "ietf:dt:proceedings", # Key function is default except with sha384-encoded key @@ -351,9 +363,8 @@ def _multiline_to_list(s): f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" ), }, - "sessions": { - "BACKEND": "ietf.utils.cache.LenientMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "sessions": REDIS_CACHE_CONFIG_COMMON + | { # No release-specific VERSION setting. "KEY_PREFIX": "ietf:dt", }, @@ -378,9 +389,8 @@ def _multiline_to_list(s): "MAX_ENTRIES": 5000, }, }, - "celery-results": { - "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", - "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "celery-results": REDIS_CACHE_CONFIG_COMMON + | { "KEY_PREFIX": "ietf:celery", }, } diff --git a/requirements.txt b/requirements.txt index 31e8ea69d1..bf7d1d26e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ django-debug-toolbar>=6.0.0 django-filter>=24.3 django-markup>=1.10 # Limited use - need to reconcile against direct use of markdown django-oidc-provider==0.8.2 # 0.8.3 changes logout flow and claim return +django-redis>=6.0.0 django-simple-history>=3.10.1 django-storages>=1.14.6 django-stubs>=4.2.7,<5 # The django-stubs version used determines the the mypy version indicated below @@ -58,8 +59,8 @@ oic>=1.7.0 # Used only by tests opentelemetry-sdk>=1.38.0 opentelemetry-instrumentation-django>=0.59b0 opentelemetry-instrumentation-psycopg2>=0.59b0 -opentelemetry-instrumentation-pymemcache>=0.59b0 opentelemetry-instrumentation-requests>=0.59b0 +opentelemetry-instrumentation-redis>=0.63b1 opentelemetry-exporter-otlp-proto-http>=1.38.0 pillow>=11.3.0 psycopg2>=2.9.10 @@ -72,11 +73,9 @@ python-dateutil>=2.9.0 types-python-dateutil>=2.9.0 python-json-logger>=3.3.0 python-magic==0.4.18 # Versions beyond the yanked .19 and .20 introduce form failures -pymemcache>=4.0.0 # for django.core.cache.backends.memcached.PyMemcacheCache python-mimeparse>=2.0.0 # from TastyPie pytz==2025.2 # Pinned as changes need to be vetted for their effect on Meeting fields types-pytz==2025.2.0.20251108 # match pytz version -typesense>=2.0.0 requests>=2.32.4 types-requests>=2.32.4 requests-mock>=1.12.1 @@ -86,6 +85,7 @@ selenium>=4.34.2 tblib>=3.1.0 # So that the django test runner provides tracebacks tlds>=2022042700 # Used to teach bleach about which TLDs currently exist tqdm>=4.67.1 +typesense>=2.0.0 unidecode>=1.4.0 urllib3>=2.5.0 weasyprint>=66.0 From 0860d3d1e86df04e1a3704c2472a82abecc18549 Mon Sep 17 00:00:00 2001 From: jennifer-richards <19472766+jennifer-richards@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:08:10 +0000 Subject: [PATCH 3/5] ci: update base image target version to 20260604T1454 --- dev/build/Dockerfile | 2 +- dev/build/TARGET_BASE | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile index 1e44f47761..4aca4c0e1e 100644 --- a/dev/build/Dockerfile +++ b/dev/build/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/ietf-tools/datatracker-app-base:20260527T1529 +FROM ghcr.io/ietf-tools/datatracker-app-base:20260604T1454 LABEL maintainer="IETF Tools Team " ENV DEBIAN_FRONTEND=noninteractive diff --git a/dev/build/TARGET_BASE b/dev/build/TARGET_BASE index 0284151019..b64be08c8c 100644 --- a/dev/build/TARGET_BASE +++ b/dev/build/TARGET_BASE @@ -1 +1 @@ -20260527T1529 +20260604T1454 From c06ee411de7c819b5b29a21af2739267a10e8f92 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 4 Jun 2026 13:34:58 -0300 Subject: [PATCH 4/5] ci: remove guard against missing CACHES in prod (#10986) --- ietf/settings.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ietf/settings.py b/ietf/settings.py index 6cfc9103ff..6d9dc00e0e 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1363,8 +1363,13 @@ def skip_unreadable_post(record): TEMPLATES[0]['OPTIONS']['context_processors'] += DEV_TEMPLATE_CONTEXT_PROCESSORS if "CACHES" not in locals(): - if SERVER_MODE == "production": - raise RuntimeError("Must set CACHES in settings_local for production mode") + # Would like to refuse to start when in prod mode, but this currently blocks + # the collectstatics call in the release GHA. We should probably arrange for that + # to have its own caches config, but in the meantime just let this fall back to + # the dev cache configuration. + # + # if SERVER_MODE == "production": + # raise RuntimeError("Must set CACHES in settings_local for production mode") CACHES = { "default": { "BACKEND": "django.core.cache.backends.dummy.DummyCache", From 9ee33fab5947b03d06f9a2e6bdad2ee2dc694595 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Fri, 5 Jun 2026 20:14:07 -0300 Subject: [PATCH 5/5] chore: revert switch to redis (#10991) --- dev/build/gunicorn.conf.py | 18 ++-- docker-compose.yml | 7 -- docker/base.Dockerfile | 2 + ietf/settings.py | 210 ++++++++++++++++++++++--------------- ietf/settings_test.py | 3 - ietf/utils/cache.py | 75 +++---------- k8s/auth.yaml | 2 + k8s/datatracker.yaml | 2 + k8s/kustomization.yaml | 1 + k8s/memcached.yaml | 88 ++++++++++++++++ k8s/settings_local.py | 46 ++++---- requirements.txt | 6 +- 12 files changed, 267 insertions(+), 193 deletions(-) create mode 100644 k8s/memcached.yaml diff --git a/dev/build/gunicorn.conf.py b/dev/build/gunicorn.conf.py index a1c572a096..03e81eac5e 100644 --- a/dev/build/gunicorn.conf.py +++ b/dev/build/gunicorn.conf.py @@ -7,6 +7,10 @@ from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.instrumentation.django import DjangoInstrumentor +from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor +from opentelemetry.instrumentation.pymemcache import PymemcacheInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor # Bind all ipv4 interfaces (nginx uses loopback, but k8s health checks don't) _BIND_PORT = os.environ.get("DATATRACKER_GUNICORN_BIND_PORT", "8000") @@ -167,15 +171,11 @@ def post_fork(server, worker): trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(otlp_exporter)) # Instrumentations - if "all" in enabled_telemetry or "django" in enabled_telemetry: - from opentelemetry.instrumentation.django import DjangoInstrumentor + if "all" in enabled_telemetry or "django" in enabled_telemetry: DjangoInstrumentor().instrument() - if "all" in enabled_telemetry or "psycopg2" in enabled_telemetry: - from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor + if "all" in enabled_telemetry or "psycopg2" in enabled_telemetry: Psycopg2Instrumentor().instrument() - if "all" in enabled_telemetry or "redis" in enabled_telemetry: - from opentelemetry.instrumentation.redis import RedisInstrumentor - RedisInstrumentor().instrument() - if "all" in enabled_telemetry or "requests" in enabled_telemetry: - from opentelemetry.instrumentation.requests import RequestsInstrumentor + if "all" in enabled_telemetry or "pymemcache" in enabled_telemetry: + PymemcacheInstrumentor().instrument() + if "all" in enabled_telemetry or "requests" in enabled_telemetry: RequestsInstrumentor().instrument() diff --git a/docker-compose.yml b/docker-compose.yml index f171fb261b..073d04b896 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,12 +90,6 @@ services: - .:/workspace - app-assets:/assets - redis: - image: redis:8 - command: ['redis-server', '--save', '10', '1', '--loglevel', 'warning'] - volumes: - - redis:/data - replicator: build: context: . @@ -175,4 +169,3 @@ volumes: app-assets: minio-data: blobdb-data: - redis: diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile index 7bf4263b38..2501636049 100644 --- a/docker/base.Dockerfile +++ b/docker/base.Dockerfile @@ -59,10 +59,12 @@ RUN apt-get update --fix-missing && apt-get install -qy --no-install-recommends libxtst6 \ libmagic-dev \ libmariadb-dev \ + libmemcached-tools \ libyang2-tools \ locales \ make \ mariadb-client \ + memcached \ nano \ netcat-traditional \ nodejs \ diff --git a/ietf/settings.py b/ietf/settings.py index 6d9dc00e0e..95f2ffefd7 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -1363,93 +1363,138 @@ def skip_unreadable_post(record): TEMPLATES[0]['OPTIONS']['context_processors'] += DEV_TEMPLATE_CONTEXT_PROCESSORS if "CACHES" not in locals(): - # Would like to refuse to start when in prod mode, but this currently blocks - # the collectstatics call in the release GHA. We should probably arrange for that - # to have its own caches config, but in the meantime just let this fall back to - # the dev cache configuration. - # - # if SERVER_MODE == "production": - # raise RuntimeError("Must set CACHES in settings_local for production mode") - CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - # "BACKEND": "django_redis.cache.RedisCache", - # "LOCATION": "redis://redis:6379/0", - # "OPTIONS": { - # "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", - # }, - # "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "VERSION": __version__, - "KEY_PREFIX": "ietf:dt", - }, - "agenda": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - # "BACKEND": "django_redis.cache.RedisCache", - # "LOCATION": "redis://redis:6379/0", - # "OPTIONS": { - # "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", - # }, - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt:agenda", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "proceedings": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - # "BACKEND": "django_redis.cache.RedisCache", - # "LOCATION": "redis://redis:6379/0", - # "OPTIONS": { - # "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", - # }, - # No release-specific VERSION setting. - "KEY_PREFIX": "ietf:dt:proceedings", - # Key function is default except with sha384-encoded key - "KEY_FUNCTION": lambda key, key_prefix, version: ( - f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" - ), - }, - "sessions": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://redis:6379/0", - "OPTIONS": { - "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingRedisClient", + if SERVER_MODE == "production": + MEMCACHED_HOST = os.environ.get("MEMCACHED_SERVICE_HOST", "127.0.0.1") + MEMCACHED_PORT = os.environ.get("MEMCACHED_SERVICE_PORT", "11211") + CACHES = { + "default": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "VERSION": __version__, + "KEY_PREFIX": "ietf:dt", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), }, - }, - "htmlized": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "LOCATION": "/var/cache/datatracker/htmlized", - "OPTIONS": { - "MAX_ENTRIES": 1000, + "agenda": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt:agenda", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), }, - }, - "pdfized": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "LOCATION": "/var/cache/datatracker/pdfized", - "OPTIONS": { - "MAX_ENTRIES": 1000, + "proceedings": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt:proceedings", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), }, - }, - "slowpages": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - "LOCATION": "/var/cache/datatracker/", - "OPTIONS": { - "MAX_ENTRIES": 5000, + "sessions": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt", }, - }, - "celery-results": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://redis:6379/0", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", + "htmlized": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": "/a/cache/datatracker/htmlized", + "OPTIONS": { + "MAX_ENTRIES": 100000, # 100,000 + }, }, - "KEY_PREFIX": "ietf:celery", - }, - } + "pdfized": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": "/a/cache/datatracker/pdfized", + "OPTIONS": { + "MAX_ENTRIES": 100000, # 100,000 + }, + }, + "slowpages": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": "/a/cache/datatracker/slowpages", + "OPTIONS": { + "MAX_ENTRIES": 5000, + }, + }, + "celery-results": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", + "KEY_PREFIX": "ietf:celery", + }, + } + else: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'ietf.utils.cache.LenientMemcacheCache', + #'LOCATION': '127.0.0.1:11211', + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "VERSION": __version__, + "KEY_PREFIX": "ietf:dt", + }, + "agenda": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + # "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + # "LOCATION": "127.0.0.1:11211", + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt:agenda", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), + }, + "proceedings": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + # "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + # "LOCATION": "127.0.0.1:11211", + # No release-specific VERSION setting. + "KEY_PREFIX": "ietf:dt:proceedings", + # Key function is default except with sha384-encoded key + "KEY_FUNCTION": lambda key, key_prefix, version: ( + f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" + ), + }, + "sessions": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, + "htmlized": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "LOCATION": "/var/cache/datatracker/htmlized", + "OPTIONS": { + "MAX_ENTRIES": 1000, + }, + }, + "pdfized": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "LOCATION": "/var/cache/datatracker/pdfized", + "OPTIONS": { + "MAX_ENTRIES": 1000, + }, + }, + "slowpages": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + #'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', + "LOCATION": "/var/cache/datatracker/", + "OPTIONS": { + "MAX_ENTRIES": 5000, + }, + }, + "celery-results": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": "app:11211", + "KEY_PREFIX": "ietf:celery", + }, + } PUBLISH_IPR_STATES = ['posted', 'removed', 'removed_objfalse'] @@ -1464,6 +1509,7 @@ def skip_unreadable_post(record): loaders = TEMPLATES[0]['OPTIONS']['loaders'] loaders = tuple(l for e in loaders for l in (e[1] if isinstance(e, tuple) and "cached.Loader" in e[0] else (e,))) TEMPLATES[0]['OPTIONS']['loaders'] = loaders + SESSION_ENGINE = "django.contrib.sessions.backends.db" if 'SECRET_KEY' not in locals(): SECRET_KEY = 'PDwXboUq!=hPjnrtG2=ge#N$Dwy+wn@uivrugwpic8mxyPfHka' diff --git a/ietf/settings_test.py b/ietf/settings_test.py index 883a582a28..e7ebc13eb2 100755 --- a/ietf/settings_test.py +++ b/ietf/settings_test.py @@ -69,9 +69,6 @@ def tempdir_with_cleanup(**kwargs): PHOTOS_DIR = os.path.join(MEDIA_ROOT, PHOTOS_DIRNAME) os.mkdir(PHOTOS_DIR) -# Use database-backed sessions for tests -SESSION_ENGINE = "django.contrib.sessions.backends.db" - # Undo any developer-dependent middleware when running the tests MIDDLEWARE = [ c for c in MIDDLEWARE if not c in DEV_MIDDLEWARE ] # pyflakes:ignore diff --git a/ietf/utils/cache.py b/ietf/utils/cache.py index 60dd2c3ae3..0baa56da2d 100644 --- a/ietf/utils/cache.py +++ b/ietf/utils/cache.py @@ -1,67 +1,20 @@ -# Copyright The IETF Trust 2026, All Rights Reserved -from typing import Optional, Union, Any +# Copyright The IETF Trust 2023, All Rights Reserved +# -*- coding: utf-8 -*- -from django.core.cache import BaseCache from django.core.cache.backends.base import DEFAULT_TIMEOUT -from django_redis.client import DefaultClient, SentinelClient -from redis import Redis -from redis.typing import KeyT, EncodableT +from django.core.cache.backends.memcached import PyMemcacheCache +from pymemcache.exceptions import MemcacheServerError -from ietf.utils.log import log +from .log import log -class EncodedValueTooBig(ValueError): - def __init__(self, *args, value_len): - super().__init__(*args) - self.value_len = value_len - - -class SizeLimitingRedisClient(DefaultClient): - """Redis DefaultClient that refuses to cache large objects - - Size applies to the literal cached value, which is _after_ serialization and - compression. - - Set size limit with MAX_ENCODED_VALUE_LEN in the OPTIONS dict. Defaults to - 1 MB. - """ - def __init__(self, server, params: dict[str, Any], backend: BaseCache) -> None: - super().__init__(server, params, backend) - self.max_encoded_value_len = self._options.get("MAX_ENCODED_VALUE_LEN", 1 << 20) - - def encode(self, value: EncodableT) -> Union[bytes, int]: - encoded = super().encode(value) - if isinstance(encoded, bytes) and len(encoded) > self.max_encoded_value_len: - raise EncodedValueTooBig(value_len=len(encoded)) - return encoded - - def set( - self, - key: KeyT, - value: EncodableT, - timeout: Optional[float] = DEFAULT_TIMEOUT, - version: Optional[int] = None, - client: Optional[Redis] = None, - nx: bool = False, - xx: bool = False, - ) -> bool: +class LenientMemcacheCache(PyMemcacheCache): + """PyMemcacheCache backend that tolerates failed inserts due to object size""" + def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): try: - return super().set(key, value, timeout, version, client, nx, xx) - except EncodedValueTooBig as err: - log( - f"Refused to cache large object for {key!r} " - f"({err.value_len} > {self.max_encoded_value_len} bytes)" - ) - return False - - -class SizeLimitingSentinelClient(SizeLimitingRedisClient, SentinelClient): - """Redis SentinelClient that refuses to cache large objects - - Size applies to the literal cached value, which is _after_ serialization and - compression. - - Set size limit with MAX_ENCODED_VALUE_LEN in the OPTIONS dict. Defaults to - 1 MB. - """ - pass + super().set(key, value, timeout, version) + except MemcacheServerError as err: + if "object too large for cache" in str(err): + log(f"Memcache failed to cache large object for {key}") + else: + raise diff --git a/k8s/auth.yaml b/k8s/auth.yaml index ef8c259933..6e63001e02 100644 --- a/k8s/auth.yaml +++ b/k8s/auth.yaml @@ -8,6 +8,8 @@ spec: selector: matchLabels: app: auth + strategy: + type: DEPLOY_STRATEGY template: metadata: labels: diff --git a/k8s/datatracker.yaml b/k8s/datatracker.yaml index 5183893bc8..af2bb6295c 100644 --- a/k8s/datatracker.yaml +++ b/k8s/datatracker.yaml @@ -8,6 +8,8 @@ spec: selector: matchLabels: app: datatracker + strategy: + type: DEPLOY_STRATEGY template: metadata: labels: diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index b1e278a914..769cb03517 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -12,5 +12,6 @@ resources: - beat.yaml - celery.yaml - datatracker.yaml + - memcached.yaml - rabbitmq.yaml - replicator.yaml diff --git a/k8s/memcached.yaml b/k8s/memcached.yaml new file mode 100644 index 0000000000..68b732d745 --- /dev/null +++ b/k8s/memcached.yaml @@ -0,0 +1,88 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: memcached +spec: + replicas: 1 + revisionHistoryLimit: 2 + selector: + matchLabels: + app: memcached + template: + metadata: + labels: + app: memcached + spec: + securityContext: + runAsNonRoot: true + containers: + # ----------------------------------------------------- + # Memcached + # ----------------------------------------------------- + - image: "memcached:1.6-alpine" + imagePullPolicy: IfNotPresent + args: ["-m", "1024"] + name: memcached + ports: + - name: memcached + containerPort: 11211 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + # memcached image sets up uid/gid 11211 + runAsUser: 11211 + runAsGroup: 11211 + resources: + requests: + cpu: 100m + memory: 100Mi + # ----------------------------------------------------- + # Memcached Exporter for Prometheus + # ----------------------------------------------------- + - image: "quay.io/prometheus/memcached-exporter:v0.14.3" + imagePullPolicy: IfNotPresent + name: memcached-exporter + ports: + - name: metrics + containerPort: 9150 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsUser: 65534 # nobody + runAsGroup: 65534 # nobody + resources: + requests: + cpu: 10m + memory: 20Mi + dnsPolicy: ClusterFirst + restartPolicy: Always + terminationGracePeriodSeconds: 30 +--- +apiVersion: v1 +kind: Service +metadata: + name: memcached + annotations: + k8s.grafana.com/scrape: "true" # this is not a bool + k8s.grafana.com/metrics.portName: "metrics" +spec: + type: ClusterIP + ports: + - port: 11211 + targetPort: memcached + protocol: TCP + name: memcached + - port: 9150 + targetPort: metrics + protocol: TCP + name: metrics + selector: + app: memcached diff --git a/k8s/settings_local.py b/k8s/settings_local.py index 560648c07f..20c5252ff0 100644 --- a/k8s/settings_local.py +++ b/k8s/settings_local.py @@ -315,29 +315,15 @@ def _multiline_to_list(s): DE_GFM_BINARY = "/usr/local/bin/de-gfm" IDSUBMIT_IDNITS_BINARY = "/usr/local/bin/idnits" +# Duplicating production cache from settings.py and using it whether we're in production mode or not +MEMCACHED_HOST = os.environ.get("DT_MEMCACHED_SERVICE_HOST", "127.0.0.1") +MEMCACHED_PORT = os.environ.get("DT_MEMCACHED_SERVICE_PORT", "11211") from ietf import __version__ -# Common config for redis caches -REDIS_SENTINEL_SERVICE = os.environ.get("DATATRACKER_REDIS_SENTINEL_HOST") -REDIS_SENTINEL_PORT = os.environ.get("DATATRACKER_REDIS_SENTINEL_PORT", "26379") -DJANGO_REDIS_CONNECTION_FACTORY = "django_redis.pool.SentinelConnectionFactory" -REDIS_CACHE_CONFIG_COMMON = { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://dt-master/0", - "OPTIONS": { - "CLIENT_CLASS": "ietf.utils.cache.SizeLimitingSentinelClient", - "MAX_ENCODED_VALUE_LEN": int( - os.environ.get("DATATRACKER_REDIS_MAX_ENCODED_VALUE_LEN", 1 << 20) - ), - "SENTINELS": [(REDIS_SENTINEL_SERVICE, REDIS_SENTINEL_PORT)], - "SENTINEL_KWARGS": {}, - "CONNECTION_POOL_CLASS": "redis.sentinel.SentinelConnectionPool", - }, -} - CACHES = { - "default": REDIS_CACHE_CONFIG_COMMON - | { + "default": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", "VERSION": __version__, "KEY_PREFIX": "ietf:dt", # Key function is default except with sha384-encoded key @@ -345,8 +331,9 @@ def _multiline_to_list(s): f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" ), }, - "agenda": REDIS_CACHE_CONFIG_COMMON - | { + "agenda": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", # No release-specific VERSION setting. "KEY_PREFIX": "ietf:dt:agenda", # Key function is default except with sha384-encoded key @@ -354,8 +341,9 @@ def _multiline_to_list(s): f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" ), }, - "proceedings": REDIS_CACHE_CONFIG_COMMON - | { + "proceedings": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", # No release-specific VERSION setting. "KEY_PREFIX": "ietf:dt:proceedings", # Key function is default except with sha384-encoded key @@ -363,8 +351,9 @@ def _multiline_to_list(s): f"{key_prefix}:{version}:{sha384(str(key).encode('utf8')).hexdigest()}" ), }, - "sessions": REDIS_CACHE_CONFIG_COMMON - | { + "sessions": { + "BACKEND": "ietf.utils.cache.LenientMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", # No release-specific VERSION setting. "KEY_PREFIX": "ietf:dt", }, @@ -389,8 +378,9 @@ def _multiline_to_list(s): "MAX_ENTRIES": 5000, }, }, - "celery-results": REDIS_CACHE_CONFIG_COMMON - | { + "celery-results": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}", "KEY_PREFIX": "ietf:celery", }, } diff --git a/requirements.txt b/requirements.txt index bf7d1d26e0..31e8ea69d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,6 @@ django-debug-toolbar>=6.0.0 django-filter>=24.3 django-markup>=1.10 # Limited use - need to reconcile against direct use of markdown django-oidc-provider==0.8.2 # 0.8.3 changes logout flow and claim return -django-redis>=6.0.0 django-simple-history>=3.10.1 django-storages>=1.14.6 django-stubs>=4.2.7,<5 # The django-stubs version used determines the the mypy version indicated below @@ -59,8 +58,8 @@ oic>=1.7.0 # Used only by tests opentelemetry-sdk>=1.38.0 opentelemetry-instrumentation-django>=0.59b0 opentelemetry-instrumentation-psycopg2>=0.59b0 +opentelemetry-instrumentation-pymemcache>=0.59b0 opentelemetry-instrumentation-requests>=0.59b0 -opentelemetry-instrumentation-redis>=0.63b1 opentelemetry-exporter-otlp-proto-http>=1.38.0 pillow>=11.3.0 psycopg2>=2.9.10 @@ -73,9 +72,11 @@ python-dateutil>=2.9.0 types-python-dateutil>=2.9.0 python-json-logger>=3.3.0 python-magic==0.4.18 # Versions beyond the yanked .19 and .20 introduce form failures +pymemcache>=4.0.0 # for django.core.cache.backends.memcached.PyMemcacheCache python-mimeparse>=2.0.0 # from TastyPie pytz==2025.2 # Pinned as changes need to be vetted for their effect on Meeting fields types-pytz==2025.2.0.20251108 # match pytz version +typesense>=2.0.0 requests>=2.32.4 types-requests>=2.32.4 requests-mock>=1.12.1 @@ -85,7 +86,6 @@ selenium>=4.34.2 tblib>=3.1.0 # So that the django test runner provides tracebacks tlds>=2022042700 # Used to teach bleach about which TLDs currently exist tqdm>=4.67.1 -typesense>=2.0.0 unidecode>=1.4.0 urllib3>=2.5.0 weasyprint>=66.0