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

Custom cache page #2311

Merged
merged 12 commits into from
Nov 20, 2024
3 changes: 3 additions & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,9 @@

# Compression level – an integer from 0 to 9. 0 means not compression
CACHE_ALL_TXS_COMPRESSION_LEVEL = env.int("CACHE_ALL_TXS_COMPRESSION_LEVEL", default=0)
CACHE_VIEW_DEFAULT_TIMEOUT = env.int(
"DEFAULT_CACHE_PAGE_TIMEOUT", default=60
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
) # 0 will disable the cache

# Contracts reindex batch configuration
# ------------------------------------------------------------------------------
Expand Down
200 changes: 200 additions & 0 deletions safe_transaction_service/history/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import json
from functools import cache, wraps
from typing import List, Optional, Union
from urllib.parse import urlencode

from django.conf import settings

from eth_typing import ChecksumAddress
from rest_framework import status
from rest_framework.response import Response

from safe_transaction_service.history.models import (
InternalTx,
ModuleTransaction,
MultisigConfirmation,
MultisigTransaction,
TokenTransfer,
)
from safe_transaction_service.utils.redis import get_redis, logger


class CacheSafeTxsView:
# Cache tags
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
LIST_MULTISIGTRANSACTIONS_VIEW_CACHE_KEY = "multisigtransactionsview"
LIST_MODULETRANSACTIONS_VIEW_CACHE_KEY = "moduletransactionsview"
LIST_TRANSFERS_VIEW_CACHE_KEY = "transfersview"

def __init__(self, cache_tag: str, address: ChecksumAddress):
self.redis = get_redis()
self.address = address
self.cache_tag = cache_tag
self.cache_name = self.get_cache_name()
moisses89 marked this conversation as resolved.
Show resolved Hide resolved

def get_cache_name(self) -> str:
"""
Calculate the cache_name from the cache_tag and address

:param cache_tag:
:param address:
:return:
"""
return f"{self.cache_tag}:{self.address}"

@cache
def is_enabled(self) -> bool:
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
"""

:return: True if cache is enabled False otherwise
"""
if settings.CACHE_VIEW_DEFAULT_TIMEOUT:
return True
else:
return False
moisses89 marked this conversation as resolved.
Show resolved Hide resolved

def get_cache_data(self, cache_path: str) -> Optional[str]:
"""
Return the cache for the provided cache_path

:param cache_path:
:return:
"""
if self.is_enabled():
logger.debug(f"Getting from cache {self.cache_name}{cache_path}")
return self.redis.hget(self.cache_name, cache_path)
else:
return None

def set_cache_data(self, cache_path: str, data: str, timeout: int):
"""
Set a cache for provided data with the provided timeout

:param cache_path:
:param data:
:param timeout:
:return:
"""
if self.is_enabled():
logger.debug(
f"Setting cache {self.cache_name}{cache_path} with TTL {timeout} seconds"
)
self.redis.hset(self.cache_name, cache_path, data)
self.redis.expire(self.cache_name, timeout)
else:
logger.warning("Cache txs view is disabled")

def remove_cache(self):
"""
Remove cache key stored in redis for the provided parameters

:param cache_name:
:return:
"""
logger.debug(f"Removing all the cache for {self.cache_name}")
self.redis.unlink(self.cache_name)


def cache_txs_view_for_address(
cache_tag: str, timeout: int = settings.CACHE_VIEW_DEFAULT_TIMEOUT
):
"""
Custom cache decorator that caches the view response.
This decorator caches the response of a view function for a specified timeout.
It allows you to cache the response based on a unique cache name, which can
be used for invalidating.

:param timeout: Cache timeout in seconds.
:param cache_name: A unique identifier for the cache entry.
"""

def decorator(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
# Get query parameters
query_params = request.request.GET.dict()
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
cache_path = f"{urlencode(query_params)}"
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
# Calculate cache_name
address = request.kwargs["address"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe "address" should be an optional parameter to the decorator, so we can use different keys in the future

Also this can throw an exception if address does not exist. It should be: request.kwargs.get("address") so you can emit a warning when you evaluate if address: later

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea here is to start by the cases where a Safe is a part of the URL path.
Later if we are happy with the results we can move forward to other endpoints. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either way, making address as a url path value configurable is just adding an optional parameter (by default 'address') to the decorator, something like caching_value_name

cache_txs_view: Optional[CacheSafeTxsView] = None
if address:
cache_txs_view = CacheSafeTxsView(cache_tag, address)
else:
logger.warning(
"Address does not exist in the request, this will not be cached"
)
cache_txs_view = None

if cache_txs_view:
# Check if response is cached
response_data = cache_txs_view.get_cache_data(cache_path)
if response_data:
return Response(
status=status.HTTP_200_OK, data=json.loads(response_data)
)

# Get response from the view
response = view_func(request, *args, **kwargs)
if response.status_code == 200:
# Just store success responses and if cache is enabled with DEFAULT_CACHE_PAGE_TIMEOUT !=0
moisses89 marked this conversation as resolved.
Show resolved Hide resolved
if cache_txs_view:
cache_txs_view.set_cache_data(
cache_path, json.dumps(response.data), timeout
)

return response

return _wrapped_view

return decorator


def remove_cache_view_by_instance(
instance: Union[
TokenTransfer,
InternalTx,
MultisigConfirmation,
MultisigTransaction,
ModuleTransaction,
]
):
"""
Remove the cache stored for instance view.

:param instance:
"""
addresses = []
cache_tag: Optional[str] = None
if isinstance(instance, TokenTransfer):
cache_tag = CacheSafeTxsView.LIST_TRANSFERS_VIEW_CACHE_KEY
addresses.append(instance.to)
addresses.append(instance._from)
elif isinstance(instance, MultisigTransaction):
cache_tag = CacheSafeTxsView.LIST_MULTISIGTRANSACTIONS_VIEW_CACHE_KEY
addresses.append(instance.safe)
elif isinstance(instance, MultisigConfirmation) and instance.multisig_transaction:
cache_tag = CacheSafeTxsView.LIST_MULTISIGTRANSACTIONS_VIEW_CACHE_KEY
addresses.append(instance.multisig_transaction.safe)
elif isinstance(instance, InternalTx):
cache_tag = CacheSafeTxsView.LIST_TRANSFERS_VIEW_CACHE_KEY
addresses.append(instance.to)
if instance._from:
addresses.append(instance._from)
elif isinstance(instance, ModuleTransaction):
cache_tag = CacheSafeTxsView.LIST_MODULETRANSACTIONS_VIEW_CACHE_KEY
addresses.append(instance.safe)

if cache_tag:
remove_cache_view_for_addresses(cache_tag, addresses)


def remove_cache_view_for_addresses(cache_tag: str, addresses: List[ChecksumAddress]):
"""
Remove several cache for the provided cache_tag and addresses

:param cache_tag:
:param addresses:
:return:
"""
for address in addresses:
cache_safe_txs = CacheSafeTxsView(cache_tag, address)
cache_safe_txs.remove_cache()
39 changes: 4 additions & 35 deletions safe_transaction_service/history/signals.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
from logging import getLogger
from typing import List, Optional, Type, Union
from typing import Type, Union

from django.conf import settings
from django.db.models import Model
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone

from eth_typing import ChecksumAddress

from safe_transaction_service.notifications.tasks import send_notification_task

from ..events.services.queue_service import get_queue_service
from .cache import remove_cache_view_by_instance
from .models import (
ERC20Transfer,
ERC721Transfer,
Expand Down Expand Up @@ -120,38 +119,6 @@ def safe_master_copy_clear_cache(
SafeMasterCopy.objects.get_version_for_address.cache_clear()


def get_safe_addresses_involved_from_db_instance(
instance: Union[
TokenTransfer,
InternalTx,
MultisigConfirmation,
MultisigTransaction,
]
) -> List[Optional[ChecksumAddress]]:
"""
Retrieves the Safe addresses involved in the provided database instance.

:param instance:
:return: List of Safe addresses from the provided instance
"""
addresses = []
if isinstance(instance, TokenTransfer):
addresses.append(instance.to)
addresses.append(instance._from)
return addresses
elif isinstance(instance, MultisigTransaction):
addresses.append(instance.safe)
return addresses
elif isinstance(instance, MultisigConfirmation) and instance.multisig_transaction:
addresses.append(instance.multisig_transaction.safe)
return addresses
elif isinstance(instance, InternalTx):
addresses.append(instance.to)
return addresses

return addresses


def _process_notification_event(
sender: Type[Model],
instance: Union[
Expand Down Expand Up @@ -179,6 +146,8 @@ def _process_notification_event(
created and deleted
), "An instance cannot be created and deleted at the same time"

logger.debug("Removing cache for object=%s", instance)
remove_cache_view_by_instance(instance)
logger.debug("Start building payloads for created=%s object=%s", created, instance)
payloads = build_event_payload(sender, instance, deleted=deleted)
logger.debug(
Expand Down
55 changes: 55 additions & 0 deletions safe_transaction_service/history/tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from django.test import TestCase

from gevent.testing import mock

from safe_transaction_service.history.cache import CacheSafeTxsView


class TestCacheSafeTxsView(TestCase):

def test_cache_name(self):
safe_address = "0x5af394e41D387d507DA9a07D33d47Cf9D8Da656d"
cache_tag = "testtag"
cache_instance = CacheSafeTxsView(cache_tag, safe_address)
self.assertEqual(
cache_instance.cache_name,
"testtag:0x5af394e41D387d507DA9a07D33d47Cf9D8Da656d",
)

def test_set_and_get_cache_data(self):
safe_address = "0x5af394e41D387d507DA9a07D33d47Cf9D8Da656d"
cache_tag = "testtag"
cache_path = "cache_path"
some_data = "TestData"
cache_instance = CacheSafeTxsView(cache_tag, safe_address)
cache_instance.set_cache_data(cache_path, some_data, 120)
self.assertEqual(
some_data, cache_instance.get_cache_data(cache_path).decode("utf-8")
)

@mock.patch(
"safe_transaction_service.history.views.settings.CACHE_VIEW_DEFAULT_TIMEOUT",
0,
)
def test_disable_cache(self):
# CACHE_VIEW_DEFAULT_TIMEOUT equals to 0 disable the cache storing
safe_address = "0x5af394e41D387d507DA9a07D33d47Cf9D8Da656d"
cache_tag = "testtag"
cache_path = "cache_path"
some_data = "TestData"
cache_instance = CacheSafeTxsView(cache_tag, safe_address)
cache_instance.set_cache_data(cache_path, some_data, 120)
self.assertIsNone(cache_instance.get_cache_data(cache_path))

def test_remove_cache(self):
safe_address = "0x5af394e41D387d507DA9a07D33d47Cf9D8Da656d"
cache_tag = "testtag"
cache_path = "cache_path"
some_data = "TestData"
cache_instance = CacheSafeTxsView(cache_tag, safe_address)
cache_instance.set_cache_data(cache_path, some_data, 120)
self.assertEqual(
some_data, cache_instance.get_cache_data(cache_path).decode("utf-8")
)
cache_instance.remove_cache()
self.assertIsNone(cache_instance.get_cache_data(cache_path))
Loading