Skip to content
Open
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
9 changes: 7 additions & 2 deletions apps/api/bin/docker-entrypoint-api-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket

# Clear Cache before starting to remove stale values
python manage.py clear_cache
# Clear Cache before starting to remove stale values unless skipped
if [ "$PLANE_SKIP_CACHE_CLEAR" != "1" ] && [ "$PLANE_SKIP_CACHE_CLEAR" != "true" ]; then
python manage.py clear_cache
else
echo "Skipping cache clear on startup (PLANE_SKIP_CACHE_CLEAR is set)"
fi


python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
9 changes: 7 additions & 2 deletions apps/api/bin/docker-entrypoint-api.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket

# Clear Cache before starting to remove stale values
python manage.py clear_cache
# Clear Cache before starting to remove stale values unless skipped
if [ "$PLANE_SKIP_CACHE_CLEAR" != "1" ] && [ "$PLANE_SKIP_CACHE_CLEAR" != "true" ]; then
python manage.py clear_cache
else
echo "Skipping cache clear on startup (PLANE_SKIP_CACHE_CLEAR is set)"
fi


# Collect static files
python manage.py collectstatic --noinput
Expand Down
45 changes: 39 additions & 6 deletions apps/api/plane/db/management/commands/clear_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# See the LICENSE file for details.

# Django imports
from django.conf import settings
from django.core.cache import cache
from django.core.management import BaseCommand

Expand All @@ -13,18 +14,50 @@ class Command(BaseCommand):
def add_arguments(self, parser):
# Positional argument
parser.add_argument("--key", type=str, nargs="?", help="Key to clear cache")
parser.add_argument(
"--all",
action="store_true",
help="Flush the entire cache database (FLUSHDB) instead of only scoped prefix keys",
)

def handle(self, *args, **options):
try:
if options["key"]:
if options.get("key"):
cache.delete(options["key"])
self.stdout.write(self.style.SUCCESS(f"Cache Cleared for key: {options['key']}"))
return

cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
# If user explicitly requests flushing the entire DB
if options.get("all"):
cache.clear()
self.stdout.write(self.style.SUCCESS("Entire cache database cleared (FLUSHDB)"))
return

# Scoped cache clear using KEY_PREFIX
key_prefix = getattr(cache, "key_prefix", None) or getattr(settings, "REDIS_KEY_PREFIX", None)

if not key_prefix:
self.stdout.write(
self.style.ERROR(
"Cannot clear cache: KEY_PREFIX is not configured. "
"Use --all if you explicitly wish to flush the entire database."
)
)
return

if not hasattr(cache, "delete_pattern"):
self.stdout.write(
self.style.ERROR(
"Cannot clear cache: Cache backend does not support delete_pattern(). "
"Use --all if you explicitly wish to flush the entire database."
)
)
return

pattern = f"{key_prefix}:*"
cache.delete_pattern(pattern)
self.stdout.write(self.style.SUCCESS(f"Cache Cleared for pattern: {pattern}"))
return
except Exception:
# Another ClientError occurred
self.stdout.write(self.style.ERROR("Failed to clear cache"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to clear cache: {e}"))
return
3 changes: 3 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,14 @@
# Redis Config
REDIS_URL = os.environ.get("REDIS_URL")
REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL
REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "plane")

if REDIS_SSL:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"KEY_PREFIX": REDIS_KEY_PREFIX,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
Expand All @@ -258,6 +260,7 @@
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"KEY_PREFIX": REDIS_KEY_PREFIX,
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
}
Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/settings/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL, # noqa
"KEY_PREFIX": REDIS_KEY_PREFIX, # noqa
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
}
Expand Down
74 changes: 74 additions & 0 deletions apps/api/plane/tests/unit/settings/test_clear_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""Unit tests for the clear_cache management command and Redis KEY_PREFIX."""

from unittest.mock import MagicMock, patch

import pytest
from django.core.management import call_command


@pytest.mark.unit
class TestClearCacheCommand:
@patch("plane.db.management.commands.clear_cache.cache")
def test_clear_cache_with_specific_key(self, mock_cache):
"""Test clearing a single key deletes only that key."""
call_command("clear_cache", key="user_session_123")
mock_cache.delete.assert_called_once_with("user_session_123")
mock_cache.clear.assert_not_called()

@patch("plane.db.management.commands.clear_cache.cache")
def test_clear_cache_scoped_by_key_prefix(self, mock_cache):
"""Test default clear_cache only deletes keys matching the prefix pattern."""
mock_cache.key_prefix = "plane"
mock_cache.delete_pattern = MagicMock()

call_command("clear_cache")

mock_cache.delete_pattern.assert_called_once_with("plane:*")
# Ensure full database flush was NOT called
mock_cache.clear.assert_not_called()

@patch("plane.db.management.commands.clear_cache.cache")
def test_clear_cache_with_all_flag_calls_flushdb(self, mock_cache):
"""Test --all flag explicitly forces a full cache.clear() / FLUSHDB."""
mock_cache.key_prefix = "plane"
mock_cache.delete_pattern = MagicMock()

call_command("clear_cache", all=True)

mock_cache.clear.assert_called_once()
mock_cache.delete_pattern.assert_not_called()

@patch("plane.db.management.commands.clear_cache.cache")
def test_clear_cache_errors_when_no_delete_pattern(self, mock_cache):
"""Test that missing delete_pattern does NOT fall back to flushdb."""
mock_cache.key_prefix = "plane"
del mock_cache.delete_pattern

call_command("clear_cache")

# Crucial safeguard: FLUSHDB must NOT be called
mock_cache.clear.assert_not_called()

@patch("plane.db.management.commands.clear_cache.cache")
def test_clear_cache_errors_when_prefix_is_empty(self, mock_cache):
"""Test that an empty prefix does NOT fall back to flushdb."""
mock_cache.key_prefix = ""
mock_cache.delete_pattern = MagicMock()

call_command("clear_cache")

# Crucial safeguard: FLUSHDB must NOT be called
mock_cache.clear.assert_not_called()
mock_cache.delete_pattern.assert_not_called()


@pytest.mark.unit
class TestRedisKeyPrefixSetting:
def test_redis_key_prefix_configured_in_caches(self, settings):
"""Test that default CACHES setting includes KEY_PREFIX."""
assert "KEY_PREFIX" in settings.CACHES["default"]
assert settings.CACHES["default"]["KEY_PREFIX"] == getattr(settings, "REDIS_KEY_PREFIX", "plane")