psycache 26.1.0__py3-none-any.whl
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.
- psycache/__init__.py +18 -0
- psycache/__main__.py +57 -0
- psycache/_async.py +203 -0
- psycache/_durations.py +55 -0
- psycache/_sql.py +33 -0
- psycache/_sync.py +198 -0
- psycache/_tables.py +24 -0
- psycache/instrumentation/__init__.py +12 -0
- psycache/instrumentation/_spans.py +173 -0
- psycache/instrumentation/prometheus.py +156 -0
- psycache/instrumentation/sentry.py +142 -0
- psycache/psycopg_pool.py +56 -0
- psycache/py.typed +0 -0
- psycache/sqlalchemy.py +51 -0
- psycache/typing.py +154 -0
- psycache-26.1.0.dist-info/METADATA +381 -0
- psycache-26.1.0.dist-info/RECORD +19 -0
- psycache-26.1.0.dist-info/WHEEL +4 -0
- psycache-26.1.0.dist-info/licenses/LICENSE +19 -0
psycache/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from ._async import AsyncCleanupService, AsyncPostgresCache
|
|
6
|
+
from ._sync import CleanupService, PostgresCache
|
|
7
|
+
from ._tables import init_db
|
|
8
|
+
from .typing import CacheInstrumentation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AsyncCleanupService",
|
|
13
|
+
"AsyncPostgresCache",
|
|
14
|
+
"CacheInstrumentation",
|
|
15
|
+
"CleanupService",
|
|
16
|
+
"PostgresCache",
|
|
17
|
+
"init_db",
|
|
18
|
+
]
|
psycache/__main__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
|
|
12
|
+
from ._tables import init_db
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _do_init_db(dsn: str) -> int:
|
|
16
|
+
try:
|
|
17
|
+
with psycopg.connect(dsn, autocommit=True) as conn:
|
|
18
|
+
init_db(conn)
|
|
19
|
+
except psycopg.Error as e:
|
|
20
|
+
print(f"psycache: init-db failed: {e}", file=sys.stderr)
|
|
21
|
+
return 1
|
|
22
|
+
|
|
23
|
+
print("psycache: initialized the cache table.")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="python -m psycache",
|
|
30
|
+
description="Maintenance commands for psycache.",
|
|
31
|
+
)
|
|
32
|
+
subparsers = parser.add_subparsers(required=True)
|
|
33
|
+
|
|
34
|
+
init_db_parser = subparsers.add_parser(
|
|
35
|
+
"init-db",
|
|
36
|
+
help="Create the psycache table and index.",
|
|
37
|
+
description="Create the psycache table and index in the database "
|
|
38
|
+
"identified by DSN.",
|
|
39
|
+
)
|
|
40
|
+
init_db_parser.add_argument(
|
|
41
|
+
"dsn",
|
|
42
|
+
metavar="DSN",
|
|
43
|
+
help="A libpq connection string, e.g. postgresql://user@host/db.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
50
|
+
parser = _build_parser()
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
return _do_init_db(args.dsn)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__": # pragma: no cover
|
|
57
|
+
raise SystemExit(main())
|
psycache/_async.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
import attrs
|
|
14
|
+
|
|
15
|
+
from psycopg.types.json import Jsonb
|
|
16
|
+
|
|
17
|
+
from . import _sql
|
|
18
|
+
from ._durations import (
|
|
19
|
+
_coerce_cleanup_interval_seconds,
|
|
20
|
+
_coerce_stop_timeout_seconds,
|
|
21
|
+
)
|
|
22
|
+
from .instrumentation._spans import (
|
|
23
|
+
_cleanup_span,
|
|
24
|
+
_flush_span,
|
|
25
|
+
_lookup_span,
|
|
26
|
+
_put_span,
|
|
27
|
+
_remove_span,
|
|
28
|
+
)
|
|
29
|
+
from .typing import AsyncCachePool, CacheInstrumentation
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _cleanup_loop(
|
|
36
|
+
cleanup: Callable[[], Awaitable[int]],
|
|
37
|
+
interval_seconds: float,
|
|
38
|
+
stop_event: asyncio.Event,
|
|
39
|
+
) -> None:
|
|
40
|
+
while not stop_event.is_set():
|
|
41
|
+
try:
|
|
42
|
+
await cleanup()
|
|
43
|
+
except Exception:
|
|
44
|
+
logger.exception("Periodic cache cleanup failed")
|
|
45
|
+
|
|
46
|
+
with suppress(TimeoutError):
|
|
47
|
+
await asyncio.wait_for(stop_event.wait(), interval_seconds)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@attrs.frozen
|
|
51
|
+
class AsyncCleanupService:
|
|
52
|
+
"""
|
|
53
|
+
Handle for a periodic cache cleanup task.
|
|
54
|
+
|
|
55
|
+
Can be used as an async context manager or stopped manually via `stop()`.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_task: asyncio.Task[None] = attrs.field(alias="task")
|
|
59
|
+
_stop_event: asyncio.Event = attrs.field(alias="stop_event")
|
|
60
|
+
|
|
61
|
+
async def stop(
|
|
62
|
+
self,
|
|
63
|
+
timeout: dt.timedelta | float | None = 5.0, # noqa: ASYNC109
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Stop the cleanup task and wait for it to finish.
|
|
67
|
+
|
|
68
|
+
Return whether the task exited before the timeout elapsed.
|
|
69
|
+
"""
|
|
70
|
+
self._stop_event.set()
|
|
71
|
+
try:
|
|
72
|
+
await asyncio.wait_for(
|
|
73
|
+
asyncio.shield(self._task),
|
|
74
|
+
_coerce_stop_timeout_seconds(timeout),
|
|
75
|
+
)
|
|
76
|
+
except TimeoutError:
|
|
77
|
+
logger.warning(
|
|
78
|
+
"Cleanup task did not stop within timeout=%s", timeout
|
|
79
|
+
)
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
async def __aenter__(self) -> Self:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
async def __aexit__(self, *args: object) -> None:
|
|
88
|
+
await self.stop()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@attrs.frozen
|
|
92
|
+
class AsyncPostgresCache:
|
|
93
|
+
"""
|
|
94
|
+
An asyncio-based Postgres cache.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
pool: AsyncCachePool
|
|
98
|
+
instrumentations: Sequence[CacheInstrumentation] = attrs.field(
|
|
99
|
+
default=(), kw_only=True
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def get_raw(
|
|
103
|
+
self, key: str, span_name: str | None = None
|
|
104
|
+
) -> dict[str, Any] | None:
|
|
105
|
+
with _lookup_span(self.instrumentations, key, span_name) as span:
|
|
106
|
+
async with self.pool.connect() as conn:
|
|
107
|
+
cur = await conn.execute(_sql.GET, (key,))
|
|
108
|
+
row = await cur.fetchone()
|
|
109
|
+
|
|
110
|
+
if row is None:
|
|
111
|
+
span.record_cache_miss()
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
span.record_cache_hit(row[1])
|
|
115
|
+
|
|
116
|
+
return row[0] # type: ignore[no-any-return]
|
|
117
|
+
|
|
118
|
+
async def put_raw(
|
|
119
|
+
self,
|
|
120
|
+
key: str,
|
|
121
|
+
value: dict[str, Any],
|
|
122
|
+
ttl: int | dt.timedelta,
|
|
123
|
+
span_name: str | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
with _put_span(self.instrumentations, key, span_name) as span:
|
|
126
|
+
if isinstance(ttl, int):
|
|
127
|
+
ttl = dt.timedelta(seconds=ttl)
|
|
128
|
+
|
|
129
|
+
expires_at = dt.datetime.now().astimezone() + ttl
|
|
130
|
+
|
|
131
|
+
async with self.pool.connect() as conn:
|
|
132
|
+
cur = await conn.execute(
|
|
133
|
+
_sql.PUT, (key, Jsonb(value), expires_at)
|
|
134
|
+
)
|
|
135
|
+
row = await cur.fetchone()
|
|
136
|
+
|
|
137
|
+
span.record_put(row[0]) # type: ignore[index] # ty: ignore[not-subscriptable]
|
|
138
|
+
|
|
139
|
+
async def remove(self, key: str) -> None:
|
|
140
|
+
with _remove_span(self.instrumentations, key) as span:
|
|
141
|
+
async with self.pool.connect() as conn:
|
|
142
|
+
await conn.execute(_sql.REMOVE, (key,))
|
|
143
|
+
|
|
144
|
+
span.record_removed()
|
|
145
|
+
|
|
146
|
+
async def cleanup_expired(self) -> int:
|
|
147
|
+
"""
|
|
148
|
+
Delete all expired cache entries.
|
|
149
|
+
|
|
150
|
+
Return the number of deleted entries.
|
|
151
|
+
"""
|
|
152
|
+
with _cleanup_span(self.instrumentations) as span:
|
|
153
|
+
async with self.pool.connect() as conn:
|
|
154
|
+
cur = await conn.execute(_sql.CLEANUP_EXPIRED)
|
|
155
|
+
num_deleted: int = cur.rowcount
|
|
156
|
+
|
|
157
|
+
span.record_cleanup(num_deleted)
|
|
158
|
+
|
|
159
|
+
return num_deleted
|
|
160
|
+
|
|
161
|
+
def start_cleanup_task(
|
|
162
|
+
self, interval: dt.timedelta | float
|
|
163
|
+
) -> AsyncCleanupService:
|
|
164
|
+
"""
|
|
165
|
+
Start a task that periodically deletes expired cache entries.
|
|
166
|
+
|
|
167
|
+
Must be called within a running asyncio event loop.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
interval:
|
|
171
|
+
Time between cleanup runs. In seconds or as a timedelta.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
An `AsyncCleanupService` that can be used to stop the task.
|
|
175
|
+
"""
|
|
176
|
+
interval_seconds = _coerce_cleanup_interval_seconds(interval)
|
|
177
|
+
|
|
178
|
+
stop_event = asyncio.Event()
|
|
179
|
+
task = asyncio.create_task(
|
|
180
|
+
_cleanup_loop(
|
|
181
|
+
self.cleanup_expired,
|
|
182
|
+
interval_seconds,
|
|
183
|
+
stop_event,
|
|
184
|
+
),
|
|
185
|
+
name="psycache-cleanup",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return AsyncCleanupService(task=task, stop_event=stop_event)
|
|
189
|
+
|
|
190
|
+
async def flush(self) -> int:
|
|
191
|
+
"""
|
|
192
|
+
Flush all cache entries.
|
|
193
|
+
|
|
194
|
+
Return the number of flushed entries.
|
|
195
|
+
"""
|
|
196
|
+
with _flush_span(self.instrumentations) as span:
|
|
197
|
+
async with self.pool.connect() as conn:
|
|
198
|
+
cur = await conn.execute(_sql.FLUSH)
|
|
199
|
+
num_flushed: int = cur.rowcount
|
|
200
|
+
|
|
201
|
+
span.record_flush(num_flushed)
|
|
202
|
+
|
|
203
|
+
return num_flushed
|
psycache/_durations.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _coerce_seconds(value: dt.timedelta | float, *, name: str) -> float:
|
|
10
|
+
seconds = (
|
|
11
|
+
value.total_seconds() if isinstance(value, dt.timedelta) else value
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if not math.isfinite(seconds):
|
|
15
|
+
msg = f"{name} must be finite"
|
|
16
|
+
raise ValueError(msg)
|
|
17
|
+
|
|
18
|
+
return seconds
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _coerce_stop_timeout_seconds(
|
|
22
|
+
timeout: dt.timedelta | float | None,
|
|
23
|
+
*,
|
|
24
|
+
max_seconds: float | None = None,
|
|
25
|
+
) -> float | None:
|
|
26
|
+
if timeout is None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
timeout_seconds = _coerce_seconds(timeout, name="stop timeout")
|
|
30
|
+
if timeout_seconds < 0:
|
|
31
|
+
msg = "stop timeout must be non-negative"
|
|
32
|
+
raise ValueError(msg)
|
|
33
|
+
|
|
34
|
+
if max_seconds is not None and timeout_seconds > max_seconds:
|
|
35
|
+
msg = f"stop timeout must not exceed {max_seconds}"
|
|
36
|
+
raise ValueError(msg)
|
|
37
|
+
|
|
38
|
+
return timeout_seconds
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _coerce_cleanup_interval_seconds(
|
|
42
|
+
interval: dt.timedelta | float,
|
|
43
|
+
*,
|
|
44
|
+
max_seconds: float | None = None,
|
|
45
|
+
) -> float:
|
|
46
|
+
interval_seconds = _coerce_seconds(interval, name="cleanup interval")
|
|
47
|
+
if interval_seconds <= 0:
|
|
48
|
+
msg = "cleanup interval must be positive"
|
|
49
|
+
raise ValueError(msg)
|
|
50
|
+
|
|
51
|
+
if max_seconds is not None and interval_seconds > max_seconds:
|
|
52
|
+
msg = f"cleanup interval must not exceed {max_seconds}"
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
|
|
55
|
+
return interval_seconds
|
psycache/_sql.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
GET = """\
|
|
6
|
+
SELECT value, pg_column_size(value)
|
|
7
|
+
FROM psycache
|
|
8
|
+
WHERE key = %s
|
|
9
|
+
AND expires_at > statement_timestamp()
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
PUT = """\
|
|
13
|
+
INSERT INTO psycache (key, value, expires_at)
|
|
14
|
+
VALUES (%s, %s, %s)
|
|
15
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
16
|
+
value = EXCLUDED.value,
|
|
17
|
+
expires_at = EXCLUDED.expires_at
|
|
18
|
+
RETURNING pg_column_size(value)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
REMOVE = """\
|
|
22
|
+
DELETE FROM psycache
|
|
23
|
+
WHERE key = %s
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
CLEANUP_EXPIRED = """\
|
|
27
|
+
DELETE FROM psycache
|
|
28
|
+
WHERE expires_at < statement_timestamp()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
FLUSH = """\
|
|
32
|
+
DELETE FROM psycache
|
|
33
|
+
"""
|
psycache/_sync.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
import attrs
|
|
13
|
+
|
|
14
|
+
from psycopg.types.json import Jsonb
|
|
15
|
+
|
|
16
|
+
from . import _sql
|
|
17
|
+
from ._durations import (
|
|
18
|
+
_coerce_cleanup_interval_seconds,
|
|
19
|
+
_coerce_stop_timeout_seconds,
|
|
20
|
+
)
|
|
21
|
+
from .instrumentation._spans import (
|
|
22
|
+
_cleanup_span,
|
|
23
|
+
_flush_span,
|
|
24
|
+
_lookup_span,
|
|
25
|
+
_put_span,
|
|
26
|
+
_remove_span,
|
|
27
|
+
)
|
|
28
|
+
from .typing import CacheInstrumentation, CachePool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cleanup_loop(
|
|
35
|
+
cleanup: Callable[[], int],
|
|
36
|
+
interval_seconds: float,
|
|
37
|
+
stop_event: threading.Event,
|
|
38
|
+
) -> None:
|
|
39
|
+
while not stop_event.is_set():
|
|
40
|
+
try:
|
|
41
|
+
cleanup()
|
|
42
|
+
except Exception:
|
|
43
|
+
logger.exception("Periodic cache cleanup failed")
|
|
44
|
+
|
|
45
|
+
stop_event.wait(interval_seconds)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@attrs.frozen
|
|
49
|
+
class CleanupService:
|
|
50
|
+
"""
|
|
51
|
+
Handle for a periodic cache cleanup thread.
|
|
52
|
+
|
|
53
|
+
Can be used as a context manager or stopped manually via `stop()`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_thread: threading.Thread = attrs.field(alias="thread")
|
|
57
|
+
_stop_event: threading.Event = attrs.field(alias="stop_event")
|
|
58
|
+
|
|
59
|
+
def stop(self, timeout: dt.timedelta | float | None = 5.0) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Stop the cleanup thread and wait for it to finish.
|
|
62
|
+
|
|
63
|
+
Return whether the thread exited before the timeout elapsed.
|
|
64
|
+
"""
|
|
65
|
+
self._stop_event.set()
|
|
66
|
+
self._thread.join(
|
|
67
|
+
_coerce_stop_timeout_seconds(
|
|
68
|
+
timeout, max_seconds=threading.TIMEOUT_MAX
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
stopped = not self._thread.is_alive()
|
|
73
|
+
if not stopped:
|
|
74
|
+
logger.warning(
|
|
75
|
+
"Cleanup thread did not stop within timeout=%s", timeout
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return stopped
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> Self:
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *args: object) -> None:
|
|
84
|
+
self.stop()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@attrs.frozen
|
|
88
|
+
class PostgresCache:
|
|
89
|
+
"""
|
|
90
|
+
A Postgres-based cache.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
pool: CachePool
|
|
94
|
+
instrumentations: Sequence[CacheInstrumentation] = attrs.field(
|
|
95
|
+
default=(), kw_only=True
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def get_raw(
|
|
99
|
+
self, key: str, span_name: str | None = None
|
|
100
|
+
) -> dict[str, Any] | None:
|
|
101
|
+
with _lookup_span(self.instrumentations, key, span_name) as span:
|
|
102
|
+
with self.pool.connect() as conn:
|
|
103
|
+
row = conn.execute(_sql.GET, (key,)).fetchone()
|
|
104
|
+
|
|
105
|
+
if row is None:
|
|
106
|
+
span.record_cache_miss()
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
span.record_cache_hit(row[1])
|
|
110
|
+
|
|
111
|
+
return row[0] # type: ignore[no-any-return]
|
|
112
|
+
|
|
113
|
+
def put_raw(
|
|
114
|
+
self,
|
|
115
|
+
key: str,
|
|
116
|
+
value: dict[str, Any],
|
|
117
|
+
ttl: int | dt.timedelta,
|
|
118
|
+
span_name: str | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
with _put_span(self.instrumentations, key, span_name) as span:
|
|
121
|
+
if isinstance(ttl, int):
|
|
122
|
+
ttl = dt.timedelta(seconds=ttl)
|
|
123
|
+
|
|
124
|
+
expires_at = dt.datetime.now().astimezone() + ttl
|
|
125
|
+
|
|
126
|
+
with self.pool.connect() as conn:
|
|
127
|
+
row = conn.execute(
|
|
128
|
+
_sql.PUT, (key, Jsonb(value), expires_at)
|
|
129
|
+
).fetchone()
|
|
130
|
+
|
|
131
|
+
span.record_put(row[0]) # type: ignore[index] # ty: ignore[not-subscriptable]
|
|
132
|
+
|
|
133
|
+
def remove(self, key: str) -> None:
|
|
134
|
+
with _remove_span(self.instrumentations, key) as span:
|
|
135
|
+
with self.pool.connect() as conn:
|
|
136
|
+
conn.execute(_sql.REMOVE, (key,))
|
|
137
|
+
|
|
138
|
+
span.record_removed()
|
|
139
|
+
|
|
140
|
+
def cleanup_expired(self) -> int:
|
|
141
|
+
"""
|
|
142
|
+
Delete all expired cache entries.
|
|
143
|
+
|
|
144
|
+
Return the number of deleted entries.
|
|
145
|
+
"""
|
|
146
|
+
with _cleanup_span(self.instrumentations) as span:
|
|
147
|
+
with self.pool.connect() as conn:
|
|
148
|
+
num_deleted: int = conn.execute(_sql.CLEANUP_EXPIRED).rowcount
|
|
149
|
+
|
|
150
|
+
span.record_cleanup(num_deleted)
|
|
151
|
+
|
|
152
|
+
return num_deleted
|
|
153
|
+
|
|
154
|
+
def flush(self) -> int:
|
|
155
|
+
"""
|
|
156
|
+
Flush all cache entries.
|
|
157
|
+
|
|
158
|
+
Return the number of flushed entries.
|
|
159
|
+
"""
|
|
160
|
+
with _flush_span(self.instrumentations) as span:
|
|
161
|
+
with self.pool.connect() as conn:
|
|
162
|
+
num_flushed: int = conn.execute(_sql.FLUSH).rowcount
|
|
163
|
+
|
|
164
|
+
span.record_flush(num_flushed)
|
|
165
|
+
|
|
166
|
+
return num_flushed
|
|
167
|
+
|
|
168
|
+
def start_cleanup_thread(
|
|
169
|
+
self, interval: dt.timedelta | float
|
|
170
|
+
) -> CleanupService:
|
|
171
|
+
"""
|
|
172
|
+
Start a daemon thread that periodically deletes expired cache entries.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
interval:
|
|
176
|
+
Time between cleanup runs. In seconds or as a timedelta.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A `CleanupService` that can be used to stop the thread.
|
|
180
|
+
"""
|
|
181
|
+
interval_seconds = _coerce_cleanup_interval_seconds(
|
|
182
|
+
interval, max_seconds=threading.TIMEOUT_MAX
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
stop_event = threading.Event()
|
|
186
|
+
thread = threading.Thread(
|
|
187
|
+
name="psycache-cleanup",
|
|
188
|
+
target=_cleanup_loop,
|
|
189
|
+
args=(
|
|
190
|
+
self.cleanup_expired,
|
|
191
|
+
interval_seconds,
|
|
192
|
+
stop_event,
|
|
193
|
+
),
|
|
194
|
+
daemon=True,
|
|
195
|
+
)
|
|
196
|
+
thread.start()
|
|
197
|
+
|
|
198
|
+
return CleanupService(thread=thread, stop_event=stop_event)
|
psycache/_tables.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_CREATE_TABLE = """\
|
|
9
|
+
CREATE UNLOGGED TABLE IF NOT EXISTS psycache (
|
|
10
|
+
key text PRIMARY KEY,
|
|
11
|
+
value jsonb NOT NULL,
|
|
12
|
+
expires_at timestamptz NOT NULL
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_CREATE_INDEX = """\
|
|
17
|
+
CREATE INDEX IF NOT EXISTS ix_psycache_expires_at
|
|
18
|
+
ON psycache (expires_at)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init_db(conn: psycopg.Connection) -> None:
|
|
23
|
+
conn.execute(_CREATE_TABLE)
|
|
24
|
+
conn.execute(_CREATE_INDEX)
|