patera-caching 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patera_caching-0.1.0/PKG-INFO +13 -0
- patera_caching-0.1.0/pyproject.toml +30 -0
- patera_caching-0.1.0/src/patera/caching/__init__.py +8 -0
- patera_caching-0.1.0/src/patera/caching/backends/__init__.py +0 -0
- patera_caching-0.1.0/src/patera/caching/backends/base_cache_backend.py +51 -0
- patera_caching-0.1.0/src/patera/caching/backends/memory_cache_backend.py +72 -0
- patera_caching-0.1.0/src/patera/caching/backends/redis_cache_backend.py +111 -0
- patera_caching-0.1.0/src/patera/caching/backends/sqlite_cache_backend.py +239 -0
- patera_caching-0.1.0/src/patera/caching/cache.py +153 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: patera-caching
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Dist: cachetools>=7.0.5
|
|
5
|
+
Requires-Dist: patera
|
|
6
|
+
Requires-Dist: redis>=5.0 ; extra == 'all'
|
|
7
|
+
Requires-Dist: aiosqlite>=0.20 ; extra == 'all'
|
|
8
|
+
Requires-Dist: redis>=5.0 ; extra == 'redis'
|
|
9
|
+
Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Provides-Extra: all
|
|
12
|
+
Provides-Extra: redis
|
|
13
|
+
Provides-Extra: sqlite
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "patera-caching"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"cachetools>=7.0.5",
|
|
7
|
+
"patera",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
redis = [
|
|
12
|
+
"redis>=5.0",
|
|
13
|
+
]
|
|
14
|
+
sqlite = [
|
|
15
|
+
"aiosqlite>=0.20",
|
|
16
|
+
]
|
|
17
|
+
all = [
|
|
18
|
+
"redis>=5.0",
|
|
19
|
+
"aiosqlite>=0.20",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
patera = { workspace = true }
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "patera.caching"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base/Blueprint class for cache implementation
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Optional, TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from patera import Patera
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseCacheBackend(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Abstract cache backend blueprint.
|
|
15
|
+
|
|
16
|
+
Subclasses should implement:
|
|
17
|
+
- configure_from_app(cls, app) -> BaseCacheBackend
|
|
18
|
+
- connect / disconnect
|
|
19
|
+
- get / set / delete / clear
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def configure_from_app(
|
|
25
|
+
cls, app: "Patera", configs: dict[str, Any]
|
|
26
|
+
) -> "BaseCacheBackend":
|
|
27
|
+
"""Create a configured backend instance using app config."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def connect(self) -> None:
|
|
31
|
+
"""Establish any required connections (no-op for memory)."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def disconnect(self) -> None:
|
|
35
|
+
"""Tear down connections (no-op for memory)."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def get(self, key: str) -> Optional[dict]:
|
|
39
|
+
"""Return cached payload dict or None."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
|
|
43
|
+
"""Store payload dict under key with optional TTL in seconds."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def delete(self, key: str) -> None:
|
|
47
|
+
"""Delete a cached entry if present."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def clear(self) -> None:
|
|
51
|
+
"""Clear the entire cache namespace."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory cache implementation
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional, cast, TYPE_CHECKING, Any
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
from cachetools import TTLCache
|
|
9
|
+
|
|
10
|
+
from .base_cache_backend import BaseCacheBackend
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from patera import Patera
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryCacheBackend(BaseCacheBackend):
|
|
17
|
+
"""
|
|
18
|
+
In-memory cache using cachetools.TTLCache for bounded size and base TTL.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Per-item TTL by storing an explicit expire timestamp alongside
|
|
22
|
+
the payload; TTLCache provides a global upper bound and eviction.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, default_ttl: int = 300, maxsize: int = 10_000):
|
|
26
|
+
self.default_ttl = int(default_ttl)
|
|
27
|
+
# Stores: key -> {payload: dict, expire: float}
|
|
28
|
+
self._cache: TTLCache[str, dict] = TTLCache(
|
|
29
|
+
maxsize=maxsize, ttl=self.default_ttl
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# ---- config ----
|
|
33
|
+
@classmethod
|
|
34
|
+
def configure_from_app(
|
|
35
|
+
cls, app: "Patera", configs: dict[str, Any]
|
|
36
|
+
) -> "MemoryCacheBackend":
|
|
37
|
+
default_ttl = configs["DURATION"]
|
|
38
|
+
maxsize = configs.get("MEMORY_MAXSIZE", 10_000)
|
|
39
|
+
return cls(default_ttl=default_ttl, maxsize=maxsize)
|
|
40
|
+
|
|
41
|
+
async def connect(self) -> None: # pragma: no cover - nothing to do
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
async def disconnect(self) -> None:
|
|
45
|
+
self._cache.clear()
|
|
46
|
+
|
|
47
|
+
async def get(self, key: str) -> Optional[dict]:
|
|
48
|
+
item = self._cache.get(key)
|
|
49
|
+
if not item:
|
|
50
|
+
return None
|
|
51
|
+
expire = item.get("expire", 0)
|
|
52
|
+
if expire < asyncio.get_event_loop().time():
|
|
53
|
+
try:
|
|
54
|
+
del self._cache[key]
|
|
55
|
+
except KeyError:
|
|
56
|
+
pass
|
|
57
|
+
return None
|
|
58
|
+
return cast(dict, item.get("payload"))
|
|
59
|
+
|
|
60
|
+
async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
|
|
61
|
+
ttl = int(duration) if duration is not None else self.default_ttl
|
|
62
|
+
expire = asyncio.get_event_loop().time() + ttl
|
|
63
|
+
self._cache[key] = {"payload": value, "expire": expire}
|
|
64
|
+
|
|
65
|
+
async def delete(self, key: str) -> None:
|
|
66
|
+
try:
|
|
67
|
+
del self._cache[key]
|
|
68
|
+
except KeyError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
async def clear(self) -> None:
|
|
72
|
+
self._cache.clear()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redis cache implementation
|
|
3
|
+
|
|
4
|
+
CACHE_REDIS_URL = "redis://localhost:6379/0" # required
|
|
5
|
+
CACHE_REDIS_PASSWORD = None # optional
|
|
6
|
+
CACHE_DURATION = 300 # optional (default TTL)
|
|
7
|
+
CACHE_KEY_PREFIX = "patera:cache:" # optional prefix/namespace
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pickle
|
|
13
|
+
from typing import Optional, cast, List, TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from redis.asyncio import Redis, from_url
|
|
16
|
+
|
|
17
|
+
from .base_cache_backend import BaseCacheBackend
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from patera import Patera
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RedisCacheBackend(BaseCacheBackend):
|
|
24
|
+
"""Redis-backed cache using binary pickled payloads."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
url: str,
|
|
29
|
+
password: Optional[str] = None,
|
|
30
|
+
default_ttl: int = 300,
|
|
31
|
+
key_prefix: str = "",
|
|
32
|
+
) -> None:
|
|
33
|
+
if not url:
|
|
34
|
+
raise ValueError("CACHE_REDIS_URL must be set for RedisCacheBackend")
|
|
35
|
+
self._url = url
|
|
36
|
+
self._password = password
|
|
37
|
+
self._client: Optional[Redis] = None
|
|
38
|
+
self.default_ttl = int(default_ttl)
|
|
39
|
+
# Normalize prefix to empty or end with ':' for nicer namespaces
|
|
40
|
+
if key_prefix and not key_prefix.endswith(":") and key_prefix != "":
|
|
41
|
+
key_prefix = key_prefix + ":"
|
|
42
|
+
self._prefix = key_prefix
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def configure_from_app(
|
|
46
|
+
cls, app: Patera, configs: dict[str, Any]
|
|
47
|
+
) -> "RedisCacheBackend":
|
|
48
|
+
url = configs.get("REDIS_URL", "")
|
|
49
|
+
password = configs.get("REDIS_PASSWORD", None)
|
|
50
|
+
ttl = cast(int, configs.get("DURATION"))
|
|
51
|
+
key_prefix = configs.get("KEY_PREFIX", "")
|
|
52
|
+
return cls(url=url, password=password, default_ttl=ttl, key_prefix=key_prefix)
|
|
53
|
+
|
|
54
|
+
async def connect(self) -> None:
|
|
55
|
+
if not self._client:
|
|
56
|
+
# decode_responses=False -> bytes in/out, good for pickled values
|
|
57
|
+
self._client = await from_url(
|
|
58
|
+
self._url,
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
decode_responses=False,
|
|
61
|
+
password=self._password,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def disconnect(self) -> None:
|
|
65
|
+
if self._client:
|
|
66
|
+
await self._client.close()
|
|
67
|
+
self._client = None
|
|
68
|
+
|
|
69
|
+
# ---- helpers ----
|
|
70
|
+
def _k(self, key: str) -> str:
|
|
71
|
+
return f"{self._prefix}{key}" if self._prefix else key
|
|
72
|
+
|
|
73
|
+
async def _ensure(self) -> Redis:
|
|
74
|
+
if not self._client:
|
|
75
|
+
# Allow lazy connect if caller forgot to call connect()
|
|
76
|
+
await self.connect()
|
|
77
|
+
assert self._client is not None
|
|
78
|
+
return self._client
|
|
79
|
+
|
|
80
|
+
async def get(self, key: str) -> Optional[dict]:
|
|
81
|
+
client = await self._ensure()
|
|
82
|
+
raw = await client.get(self._k(key))
|
|
83
|
+
if not raw:
|
|
84
|
+
return None
|
|
85
|
+
return cast(dict, pickle.loads(raw))
|
|
86
|
+
|
|
87
|
+
async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
|
|
88
|
+
client = await self._ensure()
|
|
89
|
+
ttl = int(duration) if duration is not None else self.default_ttl
|
|
90
|
+
await client.setex(self._k(key), ttl, pickle.dumps(value))
|
|
91
|
+
|
|
92
|
+
async def delete(self, key: str) -> None:
|
|
93
|
+
client = await self._ensure()
|
|
94
|
+
await client.delete(self._k(key))
|
|
95
|
+
|
|
96
|
+
async def clear(self) -> None:
|
|
97
|
+
client = await self._ensure()
|
|
98
|
+
if self._prefix and self._prefix != "":
|
|
99
|
+
keys: List[bytes] = []
|
|
100
|
+
async for k in client.scan_iter(match=f"{self._prefix}*"):
|
|
101
|
+
keys.append(k)
|
|
102
|
+
if keys:
|
|
103
|
+
# Pipeline deletion in chunks to avoid huge payloads
|
|
104
|
+
# pylint: disable-next=C0103
|
|
105
|
+
CHUNK = 1000
|
|
106
|
+
for i in range(0, len(keys), CHUNK):
|
|
107
|
+
chunk = keys[i : i + CHUNK]
|
|
108
|
+
await client.delete(*chunk)
|
|
109
|
+
else:
|
|
110
|
+
# flushes entire Redis db in case no namespace prefix is configured
|
|
111
|
+
await client.flushdb()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite cache backend
|
|
3
|
+
|
|
4
|
+
Indicated values are defaults
|
|
5
|
+
CACHE_SQLITE_PATH: str = "./pyjolt_cache.sqlite3" # file path (required for persistence)
|
|
6
|
+
CACHE_SQLITE_TABLE: str = "cache_entries" # table name
|
|
7
|
+
CACHE_KEY_PREFIX: str = "patera:cache:" # optional key namespace
|
|
8
|
+
CACHE_DURATION: int = 300 # default TTL seconds
|
|
9
|
+
CACHE_SQLITE_WAL_CHECKPOINT_MODE: str = "PASSIVE" #PASSIVE|FULL|RESTART|TRUNCATE
|
|
10
|
+
CACHE_SQLITE_WAL_CHECKPOINT_EVERY: int = 100 #run checkpoint every N write ops
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import pickle
|
|
17
|
+
import time
|
|
18
|
+
from typing import Optional, TYPE_CHECKING, Tuple, cast, Any
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
import aiosqlite
|
|
22
|
+
|
|
23
|
+
from .base_cache_backend import BaseCacheBackend
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from patera import Patera
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SQLiteCacheConfig(BaseModel):
|
|
30
|
+
CACHE_SQLITE_PATH: str = Field(
|
|
31
|
+
"./pyjolt_cache.db",
|
|
32
|
+
description="SQLite DB file path (use ':memory:' for in-memory)",
|
|
33
|
+
)
|
|
34
|
+
CACHE_SQLITE_TABLE: str = Field(
|
|
35
|
+
"cache_entries", description="Table name for cache entries"
|
|
36
|
+
)
|
|
37
|
+
CACHE_DURATION: int = Field(300, description="Cache default TTL in seconds")
|
|
38
|
+
CACHE_KEY_PREFIX: str = Field("", description="Cache key prefix/namespace")
|
|
39
|
+
CACHE_SQLITE_WAL_CHECKPOINT_MODE: str = Field(
|
|
40
|
+
"PASSIVE",
|
|
41
|
+
description="Mode for WAL checkpointing: PASSIVE|FULL|RESTART|TRUNCATE",
|
|
42
|
+
)
|
|
43
|
+
CACHE_SQLITE_WAL_CHECKPOINT_EVERY: int = Field(
|
|
44
|
+
100, description="Insert WAL checkpoint every N write operations"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SQLiteCacheBackend(BaseCacheBackend):
|
|
49
|
+
"""SQLite-backed cache using pickled payloads (async via aiosqlite)."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
db_path: str,
|
|
54
|
+
table: str = "cache_entries",
|
|
55
|
+
default_ttl: int = 300,
|
|
56
|
+
key_prefix: str = "",
|
|
57
|
+
checkpoint_mode: str = "PASSIVE",
|
|
58
|
+
checkpoint_every: int = 100,
|
|
59
|
+
) -> None:
|
|
60
|
+
if not db_path:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"CACHE_SQLITE_PATH must be provided for SQLiteCacheBackend"
|
|
63
|
+
)
|
|
64
|
+
self._db_path = db_path
|
|
65
|
+
self._table = table
|
|
66
|
+
self.default_ttl = int(default_ttl)
|
|
67
|
+
self._conn: Optional[aiosqlite.Connection] = None
|
|
68
|
+
if key_prefix and not key_prefix.endswith(":"):
|
|
69
|
+
key_prefix = key_prefix + ":"
|
|
70
|
+
self._prefix = key_prefix
|
|
71
|
+
# WAL checkpoint settings
|
|
72
|
+
self._checkpoint_mode = checkpoint_mode.upper()
|
|
73
|
+
if self._checkpoint_mode not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"CACHE_SQLITE_WAL_CHECKPOINT_MODE must be one of PASSIVE|FULL|RESTART|TRUNCATE"
|
|
76
|
+
)
|
|
77
|
+
self._checkpoint_every = max(1, int(checkpoint_every))
|
|
78
|
+
self._write_ops = 0
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def configure_from_app(
|
|
82
|
+
cls, app: "Patera", configs: dict[str, Any]
|
|
83
|
+
) -> "SQLiteCacheBackend":
|
|
84
|
+
db_path = cast(str, configs.get("SQLITE_PATH", "./pyjolt_cache.db"))
|
|
85
|
+
table = cast(str, configs.get("SQLITE_TABLE", "cache_entries"))
|
|
86
|
+
default_ttl = int(configs.get("DURATION", 300))
|
|
87
|
+
key_prefix = cast(str, configs.get("KEY_PREFIX", ""))
|
|
88
|
+
checkpoint_mode = cast(
|
|
89
|
+
str, configs.get("SQLITE_WAL_CHECKPOINT_MODE", "PASSIVE")
|
|
90
|
+
)
|
|
91
|
+
checkpoint_every = int(configs.get("SQLITE_WAL_CHECKPOINT_EVERY", 100))
|
|
92
|
+
if db_path != ":memory:":
|
|
93
|
+
os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
|
|
94
|
+
return cls(
|
|
95
|
+
db_path=db_path,
|
|
96
|
+
table=table,
|
|
97
|
+
default_ttl=default_ttl,
|
|
98
|
+
key_prefix=key_prefix,
|
|
99
|
+
checkpoint_mode=checkpoint_mode,
|
|
100
|
+
checkpoint_every=checkpoint_every,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def connect(self) -> None:
|
|
104
|
+
if self._conn:
|
|
105
|
+
return
|
|
106
|
+
self._conn = await aiosqlite.connect(self._db_path)
|
|
107
|
+
# Pragmas tuned for app caches (good perf/safety tradeoffs)
|
|
108
|
+
await self._conn.execute("PRAGMA journal_mode=WAL;")
|
|
109
|
+
await self._conn.execute("PRAGMA synchronous=NORMAL;")
|
|
110
|
+
await self._conn.execute("PRAGMA foreign_keys=ON;")
|
|
111
|
+
await self._conn.execute(
|
|
112
|
+
"PRAGMA busy_timeout=3000;"
|
|
113
|
+
) # 3s wait instead of immediate 'database is locked'. Useful for WAL mode with concurrent readers/writers (multiple app workers)
|
|
114
|
+
await self._conn.execute(
|
|
115
|
+
"PRAGMA wal_autocheckpoint=1000;"
|
|
116
|
+
) # checkpoint every ~1000 pages (~4MB)
|
|
117
|
+
await self._ensure_schema()
|
|
118
|
+
await self._conn.commit()
|
|
119
|
+
|
|
120
|
+
async def disconnect(self) -> None:
|
|
121
|
+
if self._conn is None:
|
|
122
|
+
return
|
|
123
|
+
await self._conn.close()
|
|
124
|
+
self._conn = None
|
|
125
|
+
|
|
126
|
+
async def _ensure_schema(self) -> None:
|
|
127
|
+
assert self._conn is not None
|
|
128
|
+
t = self._table
|
|
129
|
+
await self._conn.executescript(
|
|
130
|
+
f"""
|
|
131
|
+
CREATE TABLE IF NOT EXISTS {t} (
|
|
132
|
+
k TEXT PRIMARY KEY,
|
|
133
|
+
v BLOB NOT NULL,
|
|
134
|
+
expire REAL NOT NULL,
|
|
135
|
+
updated_at REAL NOT NULL
|
|
136
|
+
);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_{t}_expire ON {t}(expire);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_{t}_k_pref ON {t}(k);
|
|
139
|
+
"""
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _k(self, key: str) -> str:
|
|
143
|
+
return f"{self._prefix}{key}" if self._prefix else key
|
|
144
|
+
|
|
145
|
+
async def _cleanup_expired(self) -> None:
|
|
146
|
+
assert self._conn is not None
|
|
147
|
+
now = time.time()
|
|
148
|
+
await self._conn.execute(f"DELETE FROM {self._table} WHERE expire <= ?", (now,))
|
|
149
|
+
await self._conn.commit()
|
|
150
|
+
|
|
151
|
+
async def _maybe_checkpoint(self) -> None:
|
|
152
|
+
"""Run WAL checkpoint every N write operations."""
|
|
153
|
+
assert self._conn is not None
|
|
154
|
+
self._write_ops += 1
|
|
155
|
+
if (self._write_ops % self._checkpoint_every) == 0:
|
|
156
|
+
# PRAGMA wal_checkpoint(PASSIVE)
|
|
157
|
+
await self._conn.execute(f"PRAGMA wal_checkpoint({self._checkpoint_mode});")
|
|
158
|
+
await self._conn.commit()
|
|
159
|
+
|
|
160
|
+
async def get(self, key: str) -> Optional[dict]:
|
|
161
|
+
if self._conn is None:
|
|
162
|
+
await self.connect()
|
|
163
|
+
assert self._conn is not None
|
|
164
|
+
k = self._k(key)
|
|
165
|
+
async with self._conn.execute(
|
|
166
|
+
f"SELECT v, expire FROM {self._table} WHERE k = ?",
|
|
167
|
+
(k,),
|
|
168
|
+
) as cur:
|
|
169
|
+
row = await cur.fetchone()
|
|
170
|
+
if row is None:
|
|
171
|
+
return None
|
|
172
|
+
raw, expire = cast(Tuple[bytes, float], row)
|
|
173
|
+
now = time.time()
|
|
174
|
+
if expire <= now:
|
|
175
|
+
await self._conn.execute(f"DELETE FROM {self._table} WHERE k = ?", (k,))
|
|
176
|
+
await self._conn.commit()
|
|
177
|
+
# count deletion as a write op for checkpoint cadence
|
|
178
|
+
await self._maybe_checkpoint()
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return cast(dict, pickle.loads(raw))
|
|
182
|
+
except Exception:
|
|
183
|
+
# Drop corrupt entry
|
|
184
|
+
await self._conn.execute(f"DELETE FROM {self._table} WHERE k = ?", (k,))
|
|
185
|
+
await self._conn.commit()
|
|
186
|
+
await self._maybe_checkpoint()
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
async def set(self, key: str, value: dict, duration: Optional[int] = None) -> None:
|
|
190
|
+
if self._conn is None:
|
|
191
|
+
await self.connect()
|
|
192
|
+
assert self._conn is not None
|
|
193
|
+
ttl = int(duration) if duration is not None else self.default_ttl
|
|
194
|
+
expire = time.time() + ttl
|
|
195
|
+
k = self._k(key)
|
|
196
|
+
raw = pickle.dumps(value)
|
|
197
|
+
# Upsert with ON CONFLICT
|
|
198
|
+
await self._conn.execute(
|
|
199
|
+
f"""
|
|
200
|
+
INSERT INTO {self._table}(k, v, expire, updated_at)
|
|
201
|
+
VALUES(?, ?, ?, ?)
|
|
202
|
+
ON CONFLICT(k) DO UPDATE SET
|
|
203
|
+
v=excluded.v,
|
|
204
|
+
expire=excluded.expire,
|
|
205
|
+
updated_at=excluded.updated_at
|
|
206
|
+
""",
|
|
207
|
+
(k, raw, expire, time.time()),
|
|
208
|
+
)
|
|
209
|
+
await self._conn.commit()
|
|
210
|
+
# count insertion as a write op for checkpoint cadence
|
|
211
|
+
await self._cleanup_expired()
|
|
212
|
+
await self._maybe_checkpoint()
|
|
213
|
+
|
|
214
|
+
async def delete(self, key: str) -> None:
|
|
215
|
+
if self._conn is None:
|
|
216
|
+
await self.connect()
|
|
217
|
+
assert self._conn is not None
|
|
218
|
+
await self._conn.execute(
|
|
219
|
+
f"DELETE FROM {self._table} WHERE k = ?", (self._k(key),)
|
|
220
|
+
)
|
|
221
|
+
await self._conn.commit()
|
|
222
|
+
await self._maybe_checkpoint()
|
|
223
|
+
|
|
224
|
+
async def clear(self) -> None:
|
|
225
|
+
if self._conn is None:
|
|
226
|
+
await self.connect()
|
|
227
|
+
assert self._conn is not None
|
|
228
|
+
if self._prefix:
|
|
229
|
+
like = f"{self._prefix}%"
|
|
230
|
+
await self._conn.execute(
|
|
231
|
+
f"DELETE FROM {self._table} WHERE k LIKE ?", (like,)
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
await self._conn.execute(f"DELETE FROM {self._table}")
|
|
235
|
+
# Checkpoint after full clear to truncate WAL
|
|
236
|
+
await self._conn.execute(f"PRAGMA wal_checkpoint({self._checkpoint_mode});")
|
|
237
|
+
await self._conn.commit()
|
|
238
|
+
# reset write op count after full clear
|
|
239
|
+
self._write_ops = 0
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
""" """
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import (
|
|
7
|
+
Callable,
|
|
8
|
+
NotRequired,
|
|
9
|
+
Optional,
|
|
10
|
+
Type,
|
|
11
|
+
TypedDict,
|
|
12
|
+
cast,
|
|
13
|
+
TYPE_CHECKING,
|
|
14
|
+
Any,
|
|
15
|
+
)
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from patera.utilities import run_sync_or_async
|
|
19
|
+
from patera.base_extension import BaseExtension
|
|
20
|
+
|
|
21
|
+
from .backends.base_cache_backend import BaseCacheBackend
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from patera import Patera, Response, Request
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _CacheConfigs(BaseModel):
|
|
28
|
+
"""Configuration model for Cache extension."""
|
|
29
|
+
|
|
30
|
+
BACKEND: Optional[Type[BaseCacheBackend]] = Field(
|
|
31
|
+
default=None,
|
|
32
|
+
description="Caching backend class, must be subclass of BaseCacheBackend",
|
|
33
|
+
)
|
|
34
|
+
DURATION: Optional[int] = Field(
|
|
35
|
+
default=300, description="Default cache duration in seconds"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CacheConfig(TypedDict):
|
|
40
|
+
"""Cache configurations"""
|
|
41
|
+
|
|
42
|
+
BACKEND: NotRequired[Type[BaseCacheBackend]]
|
|
43
|
+
DURATION: NotRequired[int]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Cache(BaseExtension):
|
|
47
|
+
"""
|
|
48
|
+
Caching system for route handlers with **pluggable backend class**.
|
|
49
|
+
|
|
50
|
+
Provide caching implementation as `BACKEND` config. This should be
|
|
51
|
+
a valid caching implementation of the BaseCacheBackend class.
|
|
52
|
+
If not provided, defaults to in-memory caching (MemoryCacheBackend).
|
|
53
|
+
|
|
54
|
+
Default cache duration is set with `DURATION` config (seconds)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, configs_name: Optional[str] = "CACHE"):
|
|
58
|
+
self._app: "Optional[Patera]" = None
|
|
59
|
+
self._duration: int = 300
|
|
60
|
+
self._backend: Optional[BaseCacheBackend] = None
|
|
61
|
+
self._configs_name = cast(str, configs_name)
|
|
62
|
+
self._configs: dict[str, Any] = {}
|
|
63
|
+
|
|
64
|
+
def init_app(self, app: "Patera") -> None:
|
|
65
|
+
self._app = app
|
|
66
|
+
self._configs = app.get_conf(self._configs_name, {})
|
|
67
|
+
self._configs = self.validate_configs(self._configs, _CacheConfigs)
|
|
68
|
+
|
|
69
|
+
self._duration = self._configs["DURATION"]
|
|
70
|
+
backend_cls = self._configs.get("BACKEND", None)
|
|
71
|
+
if backend_cls is None:
|
|
72
|
+
# loads default backend - MemoryCacheBackend
|
|
73
|
+
# pylint: disable-next=C0415
|
|
74
|
+
from .backends.memory_cache_backend import MemoryCacheBackend
|
|
75
|
+
|
|
76
|
+
backend_cls = MemoryCacheBackend
|
|
77
|
+
if not issubclass(backend_cls, BaseCacheBackend):
|
|
78
|
+
raise TypeError(
|
|
79
|
+
"CACHE_BACKEND must be a class and subclass of BaseCacheBackend"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self._backend = cast(Type[BaseCacheBackend], backend_cls).configure_from_app(
|
|
83
|
+
app, self._configs
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self._app.add_extension(self)
|
|
87
|
+
self._app.add_on_startup_method(self.connect)
|
|
88
|
+
self._app.add_on_shutdown_method(self.disconnect)
|
|
89
|
+
|
|
90
|
+
async def connect(self) -> None:
|
|
91
|
+
if self._backend:
|
|
92
|
+
await self._backend.connect()
|
|
93
|
+
|
|
94
|
+
async def disconnect(self) -> None:
|
|
95
|
+
if self._backend:
|
|
96
|
+
await self._backend.disconnect()
|
|
97
|
+
|
|
98
|
+
async def set(
|
|
99
|
+
self, key: str, value: "Response", duration: Optional[int] = None
|
|
100
|
+
) -> None:
|
|
101
|
+
cached_value = {
|
|
102
|
+
"status_code": value.status_code,
|
|
103
|
+
"headers": value.headers,
|
|
104
|
+
"body": value.body,
|
|
105
|
+
}
|
|
106
|
+
await cast(BaseCacheBackend, self._backend).set(
|
|
107
|
+
key, cached_value, duration or self._duration
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def get(self, key: str, req: "Request") -> "Optional[Response]":
|
|
111
|
+
payload = await cast(BaseCacheBackend, self._backend).get(key)
|
|
112
|
+
if payload is None:
|
|
113
|
+
return None
|
|
114
|
+
return await self._make_cached_response(payload, req)
|
|
115
|
+
|
|
116
|
+
async def delete(self, key: str) -> None:
|
|
117
|
+
await cast(BaseCacheBackend, self._backend).delete(key)
|
|
118
|
+
|
|
119
|
+
async def clear(self) -> None:
|
|
120
|
+
await cast(BaseCacheBackend, self._backend).clear()
|
|
121
|
+
|
|
122
|
+
async def _make_cached_response(
|
|
123
|
+
self, cached_data: dict, req: "Request"
|
|
124
|
+
) -> "Response":
|
|
125
|
+
req.res.body = cached_data["body"]
|
|
126
|
+
req.res.status_code = cached_data["status_code"]
|
|
127
|
+
req.res.headers = cached_data["headers"]
|
|
128
|
+
return req.res
|
|
129
|
+
|
|
130
|
+
def cache(self, duration: Optional[int] = None) -> Callable:
|
|
131
|
+
"""Decorator for caching route handler results."""
|
|
132
|
+
cache = self
|
|
133
|
+
|
|
134
|
+
def decorator(handler: Callable) -> Callable:
|
|
135
|
+
@wraps(handler)
|
|
136
|
+
async def wrapper(self, *args, **kwargs) -> "Response": # type: ignore[override]
|
|
137
|
+
req: Request = args[0]
|
|
138
|
+
method: str = req.method
|
|
139
|
+
path: str = req.path
|
|
140
|
+
query_params = sorted(req.query_params.items())
|
|
141
|
+
cache_key = f"{handler.__name__}:{method}:{path}:{hash(frozenset(query_params))}"
|
|
142
|
+
|
|
143
|
+
cached_value = await cache.get(cache_key, req)
|
|
144
|
+
if cached_value is not None:
|
|
145
|
+
return cached_value
|
|
146
|
+
|
|
147
|
+
res: Response = await run_sync_or_async(handler, self, *args, **kwargs)
|
|
148
|
+
await cache.set(cache_key, res, duration)
|
|
149
|
+
return res
|
|
150
|
+
|
|
151
|
+
return wrapper
|
|
152
|
+
|
|
153
|
+
return decorator
|