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/typing.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CachePool(Protocol):
|
|
14
|
+
"""
|
|
15
|
+
Protocol for a class that provides a way to connect to a
|
|
16
|
+
`psycopg.Connection`.
|
|
17
|
+
|
|
18
|
+
Implemented by adapters that wrap a higher-level connection source
|
|
19
|
+
(for example, a psycopg pool or a SQLAlchemy engine) and hand
|
|
20
|
+
out an auto-commit `psycopg.Connection`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def connect(self) -> AbstractContextManager[psycopg.Connection]:
|
|
24
|
+
"""
|
|
25
|
+
Connect to the database and return a context manager for an
|
|
26
|
+
auto-commit connection.
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncCachePool(Protocol):
|
|
32
|
+
"""
|
|
33
|
+
Protocol for a class that provides a way to connect to a
|
|
34
|
+
`psycopg.AsyncConnection`.
|
|
35
|
+
|
|
36
|
+
Implemented by adapters that wrap a higher-level connection source
|
|
37
|
+
(for example, a psycopg pool or a SQLAlchemy engine) and hand
|
|
38
|
+
out an auto-commit `psycopg.AsyncConnection`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def connect(
|
|
42
|
+
self,
|
|
43
|
+
) -> AbstractAsyncContextManager[psycopg.AsyncConnection]:
|
|
44
|
+
"""
|
|
45
|
+
Connect to the database and return a context manager for an
|
|
46
|
+
auto-commit connection.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CacheInstrumentation(Protocol):
|
|
52
|
+
"""
|
|
53
|
+
Cache instrumentation provider.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def start_cache_get_span(
|
|
57
|
+
self, key: str, name: str | None
|
|
58
|
+
) -> AbstractContextManager[CacheGetSpan]:
|
|
59
|
+
"""
|
|
60
|
+
Start a span for a cache get operation.
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def start_cache_put_span(
|
|
65
|
+
self, key: str, name: str | None
|
|
66
|
+
) -> AbstractContextManager[CachePutSpan]:
|
|
67
|
+
"""
|
|
68
|
+
Start a span for a cache put operation.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def start_cache_remove_span(
|
|
73
|
+
self, key: str
|
|
74
|
+
) -> AbstractContextManager[CacheRemoveSpan]:
|
|
75
|
+
"""
|
|
76
|
+
Start a span for a cache removal operation.
|
|
77
|
+
"""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
def start_cache_flush_span(
|
|
81
|
+
self,
|
|
82
|
+
) -> AbstractContextManager[CacheFlushSpan]:
|
|
83
|
+
"""
|
|
84
|
+
Start a span for a cache flush operation.
|
|
85
|
+
"""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def start_cache_cleanup_span(
|
|
89
|
+
self,
|
|
90
|
+
) -> AbstractContextManager[CacheCleanupSpan]:
|
|
91
|
+
"""
|
|
92
|
+
Start a span for a cache cleanup operation.
|
|
93
|
+
"""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CacheGetSpan(Protocol):
|
|
98
|
+
"""
|
|
99
|
+
Span interface for cache get operations.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def record_cache_hit(self, item_size: int) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Record a successful cache hit with the item size in bytes.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def record_cache_miss(self) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Record a cache miss (key not found or expired).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CachePutSpan(Protocol):
|
|
114
|
+
"""
|
|
115
|
+
Span interface for cache write (put) operations.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def record_put(self, item_size: int) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Record a successful cache write with the item size in bytes.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class CacheFlushSpan(Protocol):
|
|
125
|
+
"""
|
|
126
|
+
Span interface for cache flush operations.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def record_flush(self, num_flushed: int) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Record a successful cache flush with the number of flushed entries.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CacheCleanupSpan(Protocol):
|
|
136
|
+
"""
|
|
137
|
+
Span interface for cache cleanup operations.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def record_cleanup(self, num_deleted: int) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Record a successful cleanup with the number of deleted entries.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CacheRemoveSpan(Protocol):
|
|
147
|
+
"""
|
|
148
|
+
Span interface for cache remove operations.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def record_removed(self) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Record a successful cache removal.
|
|
154
|
+
"""
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psycache
|
|
3
|
+
Version: 26.1.0
|
|
4
|
+
Summary: A psycopg-Backed PostgreSQL Cache
|
|
5
|
+
Project-URL: Documentation, https://github.com/sponsors/hynek
|
|
6
|
+
Project-URL: Changelog, https://github.com/hynek/psycache/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: GitHub, https://github.com/hynek/psycache
|
|
8
|
+
Project-URL: Funding, https://github.com/sponsors/hynek
|
|
9
|
+
Project-URL: Tidelift, https://tidelift.com?utm_source=lifter&utm_medium=referral&utm_campaign=hynek
|
|
10
|
+
Project-URL: Mastodon, https://mastodon.social/@hynek
|
|
11
|
+
Project-URL: Bluesky, https://bsky.app/profile/hynek.me
|
|
12
|
+
Project-URL: Twitter, https://twitter.com/hynek
|
|
13
|
+
Author-email: Hynek Schlawack <hs@ox.cx>
|
|
14
|
+
License-Expression: MIT
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Keywords: cache,postgres,postgresql,psycopg
|
|
17
|
+
Classifier: Development Status :: 4 - Beta
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: attrs
|
|
26
|
+
Requires-Dist: psycopg
|
|
27
|
+
Provides-Extra: pool
|
|
28
|
+
Requires-Dist: psycopg-pool; extra == 'pool'
|
|
29
|
+
Provides-Extra: prometheus
|
|
30
|
+
Requires-Dist: prometheus-client; extra == 'prometheus'
|
|
31
|
+
Provides-Extra: sentry
|
|
32
|
+
Requires-Dist: sentry-sdk; extra == 'sentry'
|
|
33
|
+
Provides-Extra: sqlalchemy
|
|
34
|
+
Requires-Dist: sqlalchemy; extra == 'sqlalchemy'
|
|
35
|
+
Provides-Extra: sqlalchemy-asyncio
|
|
36
|
+
Requires-Dist: sqlalchemy[asyncio]; extra == 'sqlalchemy-asyncio'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# *psycache*: *psycopg*-Backed PostgreSQL Cache
|
|
40
|
+
|
|
41
|
+
[](https://github.com/hynek/argon2-cffi-bindings/blob/main/LICENSE)
|
|
42
|
+
[](https://github.com/hynek/argon2-cffi-bindings/blob/main/.github/AI_POLICY.md)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
A simple key-value cache that stores JSON in PostgreSQL through [*psycopg*](https://www.psycopg.org/) 3, with TTL-based expiration and pluggable instrumentation.
|
|
46
|
+
|
|
47
|
+
- Sync and async ✔︎
|
|
48
|
+
- Type-safe ✔︎
|
|
49
|
+
- Adapters for [SQLAlchemy](https://www.sqlalchemy.org) and [*psycopg-pool*](https://www.psycopg.org/psycopg3/docs/api/pool.html) ✔︎
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
*psycache* uses an [unlogged table](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) for performance and stores values as [JSONB](https://www.postgresql.org/docs/current/datatype-json.html) for versatility.
|
|
54
|
+
|
|
55
|
+
It's a great fit when you already have PostgreSQL and need a fast cache without introducing another infrastructure parts like Redis.
|
|
56
|
+
For example, you can safely share a SQLAlchemy [`Engine`](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine) (or [`AsyncEngine`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncEngine)) with *psycache*.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Setup
|
|
60
|
+
|
|
61
|
+
Install the `psycache` package from PyPI.
|
|
62
|
+
If you plan to use it with SQLAlchemy like in the following example, install the `sqlalchemy` extra (for example, `uv pip install psycache[sqlalchemy]`).
|
|
63
|
+
|
|
64
|
+
Initialize the database table either programmatically:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import psycopg, psycache
|
|
68
|
+
|
|
69
|
+
with psycopg.connect("postgresql://psycache@127.0.0.1/psycache", autocommit=True) as conn:
|
|
70
|
+
psycache.init_db(conn)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or from the command line:
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ python -m psycache init-db postgresql://psycache@127.0.0.1/psycache
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This creates the `psycache` unlogged table and an index on `expires_at`.
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Basic Usage
|
|
83
|
+
|
|
84
|
+
Assuming you already have a SQLAlchemy `Engine` in your application, you can use `SQLAlchemyCachePool` to adapt it for use with `PostgresCache` and have a very fast cache without any further steps:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from psycache import PostgresCache
|
|
88
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
89
|
+
|
|
90
|
+
from sqlalchemy import create_engine
|
|
91
|
+
|
|
92
|
+
engine = create_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
93
|
+
cache = PostgresCache(SQLAlchemyCachePool(engine))
|
|
94
|
+
|
|
95
|
+
# Store a value with a TTL of 300 seconds.
|
|
96
|
+
cache.put_raw("user:alice", {"score": 42}, ttl=300)
|
|
97
|
+
|
|
98
|
+
# Retrieve it (returns None if missing or expired).
|
|
99
|
+
value = cache.get_raw("user:alice")
|
|
100
|
+
# {"score": 42}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
You can also pass a `datetime.timedelta` to `ttl`:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import datetime as dt
|
|
107
|
+
|
|
108
|
+
cache.put_raw("other-key", {"data": "value"}, ttl=dt.timedelta(hours=1))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Both `get_raw` and `put_raw` accept an optional *span_name* argument that is used by instrumentation.
|
|
112
|
+
Sentry uses it as the span name and Prometheus adds it as a label.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
cache.put_raw(
|
|
116
|
+
"user:alice", {"name": "alice"}, ttl=300, span_name="store user score"
|
|
117
|
+
)
|
|
118
|
+
value = cache.get_raw("user:alice", span_name="look up user score")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
*psycache* ignores expired keys, but you still need ways to delete keys manually:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
# Remove a single key.
|
|
125
|
+
cache.remove("user:alice")
|
|
126
|
+
|
|
127
|
+
# Delete all expired entries.
|
|
128
|
+
num_deleted = cache.cleanup_expired()
|
|
129
|
+
|
|
130
|
+
# Delete everything.
|
|
131
|
+
num_flushed = cache.flush()
|
|
132
|
+
|
|
133
|
+
engine.dispose()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
### Higher level
|
|
138
|
+
|
|
139
|
+
In practice, you don't want to sling raw dictionaries and remember to add span names.
|
|
140
|
+
So, wrap the cache in your own class to store and retrieve structured data:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from dataclasses import dataclass
|
|
144
|
+
from typing import Self
|
|
145
|
+
|
|
146
|
+
from psycache import PostgresCache
|
|
147
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
148
|
+
from sqlalchemy import Engine
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class UserScore:
|
|
152
|
+
name: str
|
|
153
|
+
score: int
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class UserCache:
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_engine(cls, engine: Engine, *, ttl: int = 300) -> Self:
|
|
159
|
+
return cls(PostgresCache(SQLAlchemyCachePool(engine)), ttl)
|
|
160
|
+
|
|
161
|
+
def __init__(self, cache: PostgresCache, ttl: int) -> None:
|
|
162
|
+
self._raw_cache = cache
|
|
163
|
+
self._ttl = ttl
|
|
164
|
+
|
|
165
|
+
def look_up_user(self, user_name: str) -> UserScore | None:
|
|
166
|
+
data = self._raw_cache.get_raw(
|
|
167
|
+
f"user:{user_name}",
|
|
168
|
+
span_name="look up user score",
|
|
169
|
+
)
|
|
170
|
+
if data is None:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
return UserScore(name=user_name, score=data["score"])
|
|
174
|
+
|
|
175
|
+
def store_user(self, user: UserScore) -> None:
|
|
176
|
+
self._raw_cache.put_raw(
|
|
177
|
+
f"user:{user.name}", {"score": user.score},
|
|
178
|
+
ttl=self._ttl,
|
|
179
|
+
span_name="store user score",
|
|
180
|
+
)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Packages like [*cattrs*](https://cattrs.org/) or [Pydantic](https://docs.pydantic.dev/) can reduce this boilerplate to a single line even for more complex models.
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
## Connection Pool
|
|
187
|
+
|
|
188
|
+
`PostgresCache` needs a `CachePool`: anything with a `connect()` method that yields a `psycopg.Connection`.
|
|
189
|
+
The pool adapters are optional and each lives behind an extra; the cache itself needs only `psycopg`.
|
|
190
|
+
|
|
191
|
+
`SQLAlchemyCachePool` wraps a SQLAlchemy `Engine` (requires `psycache[sqlalchemy]`):
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
195
|
+
|
|
196
|
+
pool = SQLAlchemyCachePool(engine)
|
|
197
|
+
cache = PostgresCache(pool)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`PsycopgCachePool` wraps a `psycopg_pool.ConnectionPool` (requires `psycache[pool]`):
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from psycopg_pool import ConnectionPool
|
|
204
|
+
from psycache.psycopg_pool import PsycopgCachePool
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
with ConnectionPool("postgresql://psycache@127.0.0.1/psycache") as pool:
|
|
208
|
+
cache = PostgresCache(PsycopgCachePool(pool))
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Or implement the `psycache.typing.CachePool` protocol directly:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from collections.abc import Iterator
|
|
215
|
+
from contextlib import contextmanager
|
|
216
|
+
|
|
217
|
+
import attrs
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@attrs.frozen
|
|
221
|
+
class MyCachePool:
|
|
222
|
+
@contextmanager
|
|
223
|
+
def connect(self) -> Iterator[psycopg.Connection]: ...
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
## Cleanup
|
|
228
|
+
|
|
229
|
+
For sync pools, *psycache* comes with `PostgresCache.start_cleanup_thread()` which starts a daemon thread that periodically deletes expired cache entries.
|
|
230
|
+
|
|
231
|
+
It can be used as a context manager to automatically stop the cleanup thread:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
with cache.start_cleanup_thread(interval=60):
|
|
235
|
+
...
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Or it can be stopped manually via the returned `CleanupService`'s `stop()` method:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
# Or, to manage the lifecycle manually:
|
|
242
|
+
svc = cache.start_cleanup_thread(interval=60)
|
|
243
|
+
try:
|
|
244
|
+
...
|
|
245
|
+
finally:
|
|
246
|
+
svc.stop()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
## Async
|
|
251
|
+
|
|
252
|
+
*psycache* also ships an asyncio-native API.
|
|
253
|
+
`AsyncPostgresCache` mirrors `PostgresCache`, but every operation is a coroutine.
|
|
254
|
+
It needs an `AsyncCachePool` (the `psycache.typing.AsyncCachePool` protocol): anything with an async `connect()` that yields a `psycopg.AsyncConnection`.
|
|
255
|
+
|
|
256
|
+
Two adapters are included.
|
|
257
|
+
|
|
258
|
+
`AsyncSQLAlchemyCachePool` (`psycache.sqlalchemy`) wraps a SQLAlchemy `AsyncEngine` (requires `psycache[sqlalchemy-asyncio]`):
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
262
|
+
|
|
263
|
+
from psycache import AsyncPostgresCache
|
|
264
|
+
from psycache.sqlalchemy import AsyncSQLAlchemyCachePool
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
engine = create_async_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
268
|
+
cache = AsyncPostgresCache(AsyncSQLAlchemyCachePool(engine))
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`AsyncPsycopgCachePool` (`psycache.psycopg_pool`) wraps a psycopg `psycopg_pool.AsyncConnectionPool` (requires `psycache[pool]`):
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
import asyncio
|
|
275
|
+
|
|
276
|
+
from psycopg_pool import AsyncConnectionPool
|
|
277
|
+
|
|
278
|
+
from psycache import AsyncPostgresCache
|
|
279
|
+
from psycache.psycopg_pool import AsyncPsycopgCachePool
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def main() -> None:
|
|
283
|
+
async with AsyncConnectionPool(
|
|
284
|
+
"postgresql://psycache@127.0.0.1/psycache"
|
|
285
|
+
) as pool:
|
|
286
|
+
cache = AsyncPostgresCache(AsyncPsycopgCachePool(pool))
|
|
287
|
+
|
|
288
|
+
await cache.put_raw("my-key", {"user": "alice"}, ttl=300)
|
|
289
|
+
value = await cache.get_raw("my-key")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
asyncio.run(main())
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
`AsyncPostgresCache` exposes `get_raw`, `put_raw`, `remove`, `cleanup_expired`, and `flush` – all coroutines with the same signatures as their synchronous counterparts, and it accepts the same `instrumentations`.
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
### Async cleanup
|
|
301
|
+
|
|
302
|
+
For async pools, use `AsyncPostgresCache.start_cleanup_task()` inside a running event loop.
|
|
303
|
+
|
|
304
|
+
It starts an `asyncio.Task` that periodically deletes expired cache entries.
|
|
305
|
+
It can be used as an async context manager to automatically stop the cleanup task, or it can be stopped manually via the returned `AsyncCleanupService`'s `stop()` method.
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
async def main():
|
|
309
|
+
async with cache.start_cleanup_task(interval=60):
|
|
310
|
+
...
|
|
311
|
+
|
|
312
|
+
# Or, to manage the lifecycle manually:
|
|
313
|
+
async def main():
|
|
314
|
+
svc = cache.start_cleanup_task(interval=60)
|
|
315
|
+
try:
|
|
316
|
+
...
|
|
317
|
+
finally:
|
|
318
|
+
await svc.stop()
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
## Instrumentation
|
|
323
|
+
|
|
324
|
+
*psycache* has pluggable instrumentation for observability.
|
|
325
|
+
Pass one or more providers to the `instrumentations` parameter:
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
from psycache import PostgresCache
|
|
329
|
+
from psycache.instrumentation.sentry import SentryInstrumentation
|
|
330
|
+
from psycache.instrumentation.prometheus import PrometheusInstrumentation
|
|
331
|
+
|
|
332
|
+
cache = PostgresCache(
|
|
333
|
+
pool,
|
|
334
|
+
instrumentations=(
|
|
335
|
+
SentryInstrumentation(),
|
|
336
|
+
PrometheusInstrumentation(),
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
### Prometheus
|
|
343
|
+
|
|
344
|
+
`PrometheusInstrumentation` (`psycache.instrumentation.prometheus`) exports the following metrics:
|
|
345
|
+
|
|
346
|
+
| Metric | Type | Labels | Description |
|
|
347
|
+
| --- | --- | --- | --- |
|
|
348
|
+
| `psycache_hits_total` | Counter | `span_name` | Cache hits |
|
|
349
|
+
| `psycache_misses_total` | Counter | `span_name` | Cache misses |
|
|
350
|
+
| `psycache_get_duration_seconds` | Histogram | `span_name` | Get operation latency |
|
|
351
|
+
| `psycache_put_duration_seconds` | Histogram | `span_name` | Put operation latency |
|
|
352
|
+
| `psycache_remove_duration_seconds` | Histogram | | Remove operation latency |
|
|
353
|
+
| `psycache_flush_duration_seconds` | Histogram | | Flush operation latency |
|
|
354
|
+
| `psycache_item_size_bytes` | Histogram | `span_name` | Size of cache items (from `pg_column_size`) |
|
|
355
|
+
| `psycache_flushed_entries` | Histogram | | Entries removed per flush |
|
|
356
|
+
| `psycache_cleanup_last_run_timestamp_seconds` | Gauge | | Timestamp of last cleanup |
|
|
357
|
+
| `psycache_cleanup_deleted_entries` | Gauge | | Entries removed in last cleanup |
|
|
358
|
+
|
|
359
|
+
The `span_name` label is set from the `span_name` argument to `get_raw()` and `put_raw()`. It defaults to `""` when not provided.
|
|
360
|
+
|
|
361
|
+
Requires the `prometheus` extra (`uv pip install psycache[prometheus]`).
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
### Sentry
|
|
365
|
+
|
|
366
|
+
`SentryInstrumentation` (`psycache.instrumentation.sentry`) creates [Sentry cache spans](https://docs.sentry.io/platforms/python/tracing/instrumentation/custom-instrumentation/caches-module/) for `get`, `put`, `remove`, and `flush` operations, recording `cache.hit`, `cache.item_size`, and `cache.key` data.
|
|
367
|
+
The `span_name` argument to `get_raw()` and `put_raw()` is used as the Sentry span name (defaults to `"psycache get"` / `"psycache put"`).
|
|
368
|
+
|
|
369
|
+
Requires the `sentry` extra (`uv pip install psycache[sentry]`).
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
### Custom Instrumentation
|
|
373
|
+
|
|
374
|
+
You can write your own provider by implementing the `psycache.typing.CacheInstrumentation` protocol.
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
## Credits
|
|
378
|
+
|
|
379
|
+
*psycache* is written by [Hynek Schlawack](https://hynek.me/) and distributed under the terms of the [MIT license](https://choosealicense.com/licenses/mit/).
|
|
380
|
+
|
|
381
|
+
The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/) and all my fabulous [GitHub Sponsors](https://github.com/sponsors/hynek).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
psycache/__init__.py,sha256=bVAN56Llg6y7LLX4Va5Pprl1upjf_VrPsGEDyPQCh-s,426
|
|
2
|
+
psycache/__main__.py,sha256=I4J5GjoV4_6cGMDSPt-K3ZxOFuWgQxmjyFaDfCsclcQ,1383
|
|
3
|
+
psycache/_async.py,sha256=rvLOhQrfDk9Cns70IGkvphKgXuIr3CAFvXCt9ryKLm4,5627
|
|
4
|
+
psycache/_durations.py,sha256=2TZIGgWMps9wK1ULbEXvrZEW_a1TFt26WvGdhppd8DU,1480
|
|
5
|
+
psycache/_sql.py,sha256=WzLsLalOpd7vjF_lNH6Ox7y6O6nmwcfIGPAQQNhEfWo,608
|
|
6
|
+
psycache/_sync.py,sha256=3yVe5g2-2FUmAhMcLqO6RmfvbFB7suUs4139a8YRR9g,5283
|
|
7
|
+
psycache/_tables.py,sha256=TkOY_efWmJI2_K0ngBQAxlc6r_XfulS5a1llVO162FI,489
|
|
8
|
+
psycache/psycopg_pool.py,sha256=pim82iroFlcdBFDPOswpzB4-En3BYr9IFY2PdnsGWFo,1437
|
|
9
|
+
psycache/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
psycache/sqlalchemy.py,sha256=FNh4Pc8UE7zZG0g76ssOpd1WvbOJ03EwF6AHP3zdPgs,1301
|
|
11
|
+
psycache/typing.py,sha256=wpFLyxsA6NwIsaWyKrWwYn1P1m8oAGgFwMasOcL3g4w,3711
|
|
12
|
+
psycache/instrumentation/__init__.py,sha256=QtaKfHlmY-hwxm_Wi2xnRW_qS1-rxDQAWKjTgKjS9d4,199
|
|
13
|
+
psycache/instrumentation/_spans.py,sha256=biX_jGQ_k-yJ_B5GrSIxgVgzuU810IwR17F9PRdwn8s,4237
|
|
14
|
+
psycache/instrumentation/prometheus.py,sha256=FQp0xP1UF_Sb2FuackyiAkGNvBJYNgc8xRpHej4eTR8,4120
|
|
15
|
+
psycache/instrumentation/sentry.py,sha256=FJBoGgZCW7AMovNfxrZukI5C_p0EHe4GRTUSyadC_AQ,3482
|
|
16
|
+
psycache-26.1.0.dist-info/METADATA,sha256=Bb0hSCWomvpGTEgoki86jLDMtl2WTmEx6NPyO8EZXjo,12903
|
|
17
|
+
psycache-26.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
18
|
+
psycache-26.1.0.dist-info/licenses/LICENSE,sha256=T85snmXoD8X6cIj_-OUZ-bybKCrV-93r-BVaG5MiVjY,1089
|
|
19
|
+
psycache-26.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Hynek Schlawack and the psycache contributors
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|