(20260109) Almost done with Cosec-TCAOFF sync.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
|
||||
AUTHOR:
|
||||
|
||||
Khushal P Soonderji
|
||||
|
||||
DATE:
|
||||
|
||||
CREATED: Tue, 25th Nov, 2025
|
||||
UPDATED: Thu, 27th Nov, 2025
|
||||
|
||||
OBJECTIVE:
|
||||
|
||||
To define a caching class that uses in-RAM dicts to hold cached data.
|
||||
This is a good way to cache data for a single-machine, single-process service. The cached data is rapidly
|
||||
available (since it's held in RAM), but it cannot be shared across various instances.
|
||||
|
||||
WARNING:
|
||||
--------
|
||||
THIS IS A STRONGLY STATEFUL WAY OF IMPLEMENTING CACHING. THE USER'S REQUEST WILL HAVE TO HIT EXACTLY THE SAME
|
||||
MACHINE AND EXACTLY THE SAME PROCESS ON THAT MACHINE FOR THE CACHE TO BE ANY GOOD.
|
||||
|
||||
The idea is simple:
|
||||
-------------------
|
||||
Everything that needs to be cached should have a key (identifier) and a value (the actual data). We hold the
|
||||
cache in a dictionary in Python where the identifier becomes the key of the dict, and the value is the cached
|
||||
data.
|
||||
|
||||
REFERENCES:
|
||||
|
||||
N/A
|
||||
|
||||
DOWNLOADS:
|
||||
|
||||
N/A
|
||||
|
||||
"""
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** IMPORT ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# To make sibling directories accessible for imports:
|
||||
import sys
|
||||
sys.path.append(".")
|
||||
sys.path.append("..")
|
||||
|
||||
# Other utils:
|
||||
from utils_v2.date_time import date_time
|
||||
from utils_v2.string import regex
|
||||
from utils_v2.cache_v2.async_base import AsyncCachingBase
|
||||
|
||||
# To work with datatypes:
|
||||
from typing import List, Any
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** MACROS / ONE-TIME INIT ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# --- Nothing Yet
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** VARIABLES ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# --- Nothing Yet
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** CLASSES ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
class AsyncMemCache(AsyncCachingBase):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
expiry_check_interval: int | float = 60.0,
|
||||
debug = True,
|
||||
debug_prefix = "AMemCache | "
|
||||
):
|
||||
|
||||
"""
|
||||
Implements a simple cache in RAM that holds and returns all native datatypes. This is good for single-process
|
||||
implementations that run on a single machine because the cached data will not be accessible to other processes
|
||||
even when running on the same machine. Also, all timestamps are normalized to UTC.
|
||||
NOTE: It is NOT async, actually. It has been built on top of an async class so it has been declared as if it is.
|
||||
:param expiry_check_interval: How frequently to check for expiry of all the cached data. When you request very
|
||||
specific data, its expiry will be checked before serving; but this interval defines a general cleanup to
|
||||
release memory.
|
||||
:param debug: Whether, or not, you want to show debugging messages from the start.
|
||||
:param debug_prefix: The prefix text to show with the debugging messages.
|
||||
"""
|
||||
|
||||
# Invoke the parent class's constructor:
|
||||
super().__init__(
|
||||
debug = debug,
|
||||
debug_prefix = debug_prefix
|
||||
)
|
||||
|
||||
# Create a local dict that will hold the cached data:
|
||||
self.__cached_data = {}
|
||||
self.__expiry_check_interval = expiry_check_interval
|
||||
self.__last_expiry_check_ts = date_time.get_current_utc_date_time(as_string = False).timestamp()
|
||||
|
||||
# ┓┏ ┓
|
||||
# ┣┫┏┓┃┏┓┏┓┏┓┏
|
||||
# ┛┗┗ ┗┣┛┗ ┛ ┛
|
||||
# ┛
|
||||
|
||||
async def __expired(
|
||||
self,
|
||||
key: str
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
Tells you if a key has expired.
|
||||
:param key: The key that you want to identify.
|
||||
:return: True if expired, False if valid.
|
||||
"""
|
||||
|
||||
# If the key doesn't exist, it has expired:
|
||||
if key not in self.__cached_data: return True
|
||||
|
||||
# If the key exists, but the current timestamp has crossed the expiry timestamp;
|
||||
# we also try to delete the key in that case:
|
||||
elif self.now_utc >= self.__cached_data[key]["exp"]:
|
||||
await self.delete(key, raise_exception = False)
|
||||
return True
|
||||
|
||||
# Otherwise it is yet valid (not expired):
|
||||
else: return False
|
||||
|
||||
async def __clear_expired_keys(self) -> None:
|
||||
|
||||
"""
|
||||
Clears all the expired cached data.
|
||||
:return: None.
|
||||
"""
|
||||
|
||||
# If the expiry check interval has not been crossed,
|
||||
# we need not go through the process:
|
||||
ref_utc = self.now_utc
|
||||
if ref_utc - self.__last_expiry_check_ts < self.__expiry_check_interval: return
|
||||
|
||||
# Otherwise, we note down the current timestamp and proceed:
|
||||
self.__last_expiry_check_ts = ref_utc
|
||||
|
||||
# Enlist all the keys that need to be deleted:
|
||||
to_del = [k for k in self.__cached_data.keys() if self.__expired(k)]
|
||||
|
||||
# Delete the expired keys:
|
||||
for k in to_del: await self.delete(k, raise_exception = False)
|
||||
|
||||
# ┏┓
|
||||
# ┃ ┏┓┏┓┏┓
|
||||
# ┗┛┗┛┛ ┗
|
||||
|
||||
async def list_keys(
|
||||
self,
|
||||
match: str | None = None,
|
||||
raise_exception: bool = False
|
||||
) -> List[str] | None:
|
||||
|
||||
"""
|
||||
To get a list of all the keys that match the pattern.
|
||||
:param match: To match a glob-style pattern. THIS IS NOT FULL-FLEDGED REGEX.
|
||||
:param raise_exception: Whether to raise or suppress exceptions.
|
||||
:return: The list of keys if successful, else None.
|
||||
"""
|
||||
|
||||
# Clear the expired keys:
|
||||
await self.__clear_expired_keys()
|
||||
|
||||
# Start with an empty list:
|
||||
keys_list = []
|
||||
|
||||
try:
|
||||
|
||||
# Enlist all the keys,
|
||||
# test the pattern against all the keys and keep only those that match:
|
||||
for k, v in self.__cached_data.items():
|
||||
if not await self.__expired(k):
|
||||
if match is None: keys_list.append(k)
|
||||
elif regex.match(text = str(k), pattern = match):
|
||||
keys_list.append(k)
|
||||
|
||||
# Done here:
|
||||
return list(set(keys_list)) if keys_list else None
|
||||
|
||||
# If an exception occurs in the process:
|
||||
except Exception as exception:
|
||||
self._printer("LIST ERR!", exception)
|
||||
if raise_exception: raise
|
||||
else: return None
|
||||
|
||||
async def ttl(
|
||||
self,
|
||||
key: str | bytes,
|
||||
raise_exception: bool = False
|
||||
) -> int | float | None:
|
||||
|
||||
"""
|
||||
To get the no. of seconds till the expiry of some key.
|
||||
:param key: The key to check the expiry of.
|
||||
:param raise_exception: Whether to raise or suppress exceptions.
|
||||
:return: -1 if the key is persistent (i.e., no expiry time set), -2 if the key does not exist, or the time left
|
||||
in seconds if the key exists and is not persistent. If something goes wrong, you will get a null value.
|
||||
"""
|
||||
|
||||
# Clear the expired keys:
|
||||
await self.__clear_expired_keys()
|
||||
|
||||
# Start by assuming failure:
|
||||
ttl = None
|
||||
|
||||
try:
|
||||
|
||||
# Get the expiry:
|
||||
exp_utc = self.__cached_data.get(key, {}).get("exp", None)
|
||||
if exp_utc is None: ttl = -2
|
||||
else: ttl = exp_utc - self.now_utc
|
||||
return ttl
|
||||
|
||||
# If an exception occurs in the process:
|
||||
except Exception as exception:
|
||||
self._printer("TTL ERR!", key, exception)
|
||||
if raise_exception: raise
|
||||
else: return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str | bytes,
|
||||
value: Any,
|
||||
expiry: float = None,
|
||||
raise_exception: bool = False
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
Saves some value to the cache. If an expiry is specified, the data will be deleted after that many seconds.
|
||||
:param key: The key with which the data will be stored and retrieved.
|
||||
:param value: The value to store.
|
||||
:param expiry: The time in seconds after which the data will expire. Must be a positive number.
|
||||
:param raise_exception: Whether to raise or suppress exceptions.
|
||||
:return: True if cached, else False.
|
||||
"""
|
||||
|
||||
# Clear the expired keys:
|
||||
await self.__clear_expired_keys()
|
||||
|
||||
try:
|
||||
|
||||
# If the expiry is not specified,
|
||||
# make it an unreasonably far off future date:
|
||||
if expiry is None: expiry = self.now_utc + self.LONG_EXPIRY
|
||||
|
||||
# Here we actually try to set the data:
|
||||
self.__cached_data[key] = {
|
||||
"val": value,
|
||||
"exp": self.now_utc + expiry
|
||||
}
|
||||
|
||||
# Done here:
|
||||
return True
|
||||
|
||||
# If an exception occurs in the process:
|
||||
except Exception as exception:
|
||||
self._printer("SET ERR!", key, exception)
|
||||
if raise_exception: raise
|
||||
else: return False
|
||||
|
||||
async def get(
|
||||
self,
|
||||
key: str | bytes,
|
||||
raise_exception: bool = False,
|
||||
on_fail: Any = None
|
||||
) -> Any:
|
||||
|
||||
"""
|
||||
Retrieve the cached value.
|
||||
:param key: The key with which the data was saved.
|
||||
:param raise_exception: Whether to raise or suppress exceptions.
|
||||
:param on_fail: What to return if the process fails due to an exception.
|
||||
:return: The retrieved data or null if not found.
|
||||
"""
|
||||
|
||||
# Clear the expired keys:
|
||||
await self.__clear_expired_keys()
|
||||
|
||||
try:
|
||||
|
||||
# Here we try to fetch the data:
|
||||
data = on_fail
|
||||
raw = self.__cached_data[key]
|
||||
|
||||
# Check for expiry if it was a cache hit:
|
||||
if await self.__expired(key) and raise_exception: raise RuntimeError(f"Key '{key}' not found.")
|
||||
else: data = raw["val"]
|
||||
|
||||
# if self.now_utc >= raw["exp"]: await self.delete(key, raise_exception = True)
|
||||
# else: data = raw["val"]
|
||||
|
||||
# Done here:
|
||||
return data
|
||||
|
||||
# If an exception occurs in the process:
|
||||
except Exception as exception:
|
||||
self._printer("CACHE MISS!", key, exception)
|
||||
if raise_exception: raise
|
||||
else: return on_fail
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
key: str | bytes,
|
||||
raise_exception: bool = False
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
Prematurely delete the value from the cache before it expires.
|
||||
:param key: The key with which the data was saved.
|
||||
:param raise_exception: Whether to raise or suppress exceptions.
|
||||
:return: True if deleted, else False.
|
||||
"""
|
||||
|
||||
# Clear the expired keys:
|
||||
await self.__clear_expired_keys()
|
||||
|
||||
try:
|
||||
|
||||
# Try to manually delete the key before expiry:
|
||||
response = self.__cached_data.pop(key, None)
|
||||
return True if response else False
|
||||
|
||||
# If an exception occurs in the process:
|
||||
except Exception as exception:
|
||||
self._printer("DEL ERR!", key, exception)
|
||||
if raise_exception: raise
|
||||
else: return False
|
||||
|
||||
async def count(
|
||||
self,
|
||||
key: str | bytes,
|
||||
value: int = 1,
|
||||
expiry: float = None,
|
||||
raise_exception: bool = False
|
||||
) -> int | None:
|
||||
|
||||
"""
|
||||
To use simple counters. If the counter (identified by the 'key') exists, it will be incremented, else the
|
||||
counter will be created and the value will be incremented from 0.
|
||||
:param key: The name of the counter.
|
||||
:param value: The amount to increment the value by. Send negative values to count backwards.
|
||||
:param expiry: The time (in seconds) in which the counter expires. Starts from the time the counter is created.
|
||||
This value has to be an integer. If a float is passed, the value will be rounded off.
|
||||
:param raise_exception: Whether to raise or suppress exceptions.
|
||||
:return: The latest value of the counter. Will be null if something went wrong and the exception was suppressed.
|
||||
"""
|
||||
|
||||
# Clear the expired keys:
|
||||
await self.__clear_expired_keys()
|
||||
|
||||
try:
|
||||
|
||||
# If the key already exists and hasn't expired yet:
|
||||
if not await self.__expired(key):
|
||||
self.__cached_data[key]["val"] = self.__cached_data[key]["val"] + value
|
||||
|
||||
# If the key has expired or if it is new:
|
||||
else:
|
||||
await self.set(
|
||||
key = key,
|
||||
value = value,
|
||||
expiry = expiry,
|
||||
raise_exception = True
|
||||
)
|
||||
|
||||
# Return the new value:
|
||||
return await self.get(key, raise_exception = True)
|
||||
|
||||
# If an exception occurs in the process:
|
||||
except Exception as exception:
|
||||
self._printer("COUNT ERR!", key, value, exception)
|
||||
if raise_exception: raise
|
||||
else: return None
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** FUNCTIONS ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
# --- Nothing Yet
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# ***** ****
|
||||
# *** MAIN PROGRAM ***
|
||||
# ***** ****
|
||||
# *****************************************************************************************************************
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
pass
|
||||
Reference in New Issue
Block a user