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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Generic span helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator, Sequence
|
|
10
|
+
from contextlib import ExitStack, contextmanager
|
|
11
|
+
|
|
12
|
+
from psycache.typing import (
|
|
13
|
+
CacheCleanupSpan,
|
|
14
|
+
CacheFlushSpan,
|
|
15
|
+
CacheGetSpan,
|
|
16
|
+
CacheInstrumentation,
|
|
17
|
+
CachePutSpan,
|
|
18
|
+
CacheRemoveSpan,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _AggregatingGetSpan:
|
|
23
|
+
def __init__(self, spans: Sequence[CacheGetSpan]) -> None:
|
|
24
|
+
self._spans = spans
|
|
25
|
+
|
|
26
|
+
def record_cache_hit(self, item_size: int) -> None:
|
|
27
|
+
for span in self._spans:
|
|
28
|
+
span.record_cache_hit(item_size)
|
|
29
|
+
|
|
30
|
+
def record_cache_miss(self) -> None:
|
|
31
|
+
for span in self._spans:
|
|
32
|
+
span.record_cache_miss()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _AggregatingPutSpan:
|
|
36
|
+
def __init__(self, spans: Sequence[CachePutSpan]) -> None:
|
|
37
|
+
self._spans = spans
|
|
38
|
+
|
|
39
|
+
def record_put(self, item_size: int) -> None:
|
|
40
|
+
for span in self._spans:
|
|
41
|
+
span.record_put(item_size)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _AggregatingRemoveSpan:
|
|
45
|
+
def __init__(self, spans: Sequence[CacheRemoveSpan]) -> None:
|
|
46
|
+
self._spans = spans
|
|
47
|
+
|
|
48
|
+
def record_removed(self) -> None:
|
|
49
|
+
for span in self._spans:
|
|
50
|
+
span.record_removed()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _AggregatingCleanupSpan:
|
|
54
|
+
def __init__(self, spans: Sequence[CacheCleanupSpan]) -> None:
|
|
55
|
+
self._spans = spans
|
|
56
|
+
|
|
57
|
+
def record_cleanup(self, num_deleted: int) -> None:
|
|
58
|
+
for span in self._spans:
|
|
59
|
+
span.record_cleanup(num_deleted)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _AggregatingFlushSpan:
|
|
63
|
+
def __init__(self, spans: Sequence[CacheFlushSpan]) -> None:
|
|
64
|
+
self._spans = spans
|
|
65
|
+
|
|
66
|
+
def record_flush(self, num_flushed: int) -> None:
|
|
67
|
+
for span in self._spans:
|
|
68
|
+
span.record_flush(num_flushed)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NoopAnySpan:
|
|
72
|
+
def record_cache_hit(self, item_size: int) -> None:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def record_cache_miss(self) -> None:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def record_put(self, item_size: int) -> None:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
def record_removed(self) -> None:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def record_cleanup(self, num_deleted: int) -> None:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def record_flush(self, num_flushed: int) -> None:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def _lookup_span(
|
|
93
|
+
instrumentations: Sequence[CacheInstrumentation],
|
|
94
|
+
key: str,
|
|
95
|
+
name: str | None,
|
|
96
|
+
) -> Iterator[CacheGetSpan]:
|
|
97
|
+
if not instrumentations:
|
|
98
|
+
yield NoopAnySpan()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
with ExitStack() as stack:
|
|
102
|
+
spans = [
|
|
103
|
+
stack.enter_context(inst.start_cache_get_span(key, name))
|
|
104
|
+
for inst in instrumentations
|
|
105
|
+
]
|
|
106
|
+
yield _AggregatingGetSpan(spans)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def _put_span(
|
|
111
|
+
instrumentations: Sequence[CacheInstrumentation],
|
|
112
|
+
key: str,
|
|
113
|
+
name: str | None,
|
|
114
|
+
) -> Iterator[CachePutSpan]:
|
|
115
|
+
if not instrumentations:
|
|
116
|
+
yield NoopAnySpan()
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
with ExitStack() as stack:
|
|
120
|
+
spans = [
|
|
121
|
+
stack.enter_context(inst.start_cache_put_span(key, name))
|
|
122
|
+
for inst in instrumentations
|
|
123
|
+
]
|
|
124
|
+
yield _AggregatingPutSpan(spans)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@contextmanager
|
|
128
|
+
def _remove_span(
|
|
129
|
+
instrumentations: Sequence[CacheInstrumentation],
|
|
130
|
+
key: str,
|
|
131
|
+
) -> Iterator[CacheRemoveSpan]:
|
|
132
|
+
if not instrumentations:
|
|
133
|
+
yield NoopAnySpan()
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
with ExitStack() as stack:
|
|
137
|
+
spans = [
|
|
138
|
+
stack.enter_context(inst.start_cache_remove_span(key))
|
|
139
|
+
for inst in instrumentations
|
|
140
|
+
]
|
|
141
|
+
yield _AggregatingRemoveSpan(spans)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@contextmanager
|
|
145
|
+
def _cleanup_span(
|
|
146
|
+
instrumentations: Sequence[CacheInstrumentation],
|
|
147
|
+
) -> Iterator[CacheCleanupSpan]:
|
|
148
|
+
if not instrumentations:
|
|
149
|
+
yield NoopAnySpan()
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
with ExitStack() as stack:
|
|
153
|
+
spans = [
|
|
154
|
+
stack.enter_context(inst.start_cache_cleanup_span())
|
|
155
|
+
for inst in instrumentations
|
|
156
|
+
]
|
|
157
|
+
yield _AggregatingCleanupSpan(spans)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@contextmanager
|
|
161
|
+
def _flush_span(
|
|
162
|
+
instrumentations: Sequence[CacheInstrumentation],
|
|
163
|
+
) -> Iterator[CacheFlushSpan]:
|
|
164
|
+
if not instrumentations:
|
|
165
|
+
yield NoopAnySpan()
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
with ExitStack() as stack:
|
|
169
|
+
spans = [
|
|
170
|
+
stack.enter_context(inst.start_cache_flush_span())
|
|
171
|
+
for inst in instrumentations
|
|
172
|
+
]
|
|
173
|
+
yield _AggregatingFlushSpan(spans)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Prometheus-backed cache instrumentation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
|
|
12
|
+
from prometheus_client import Counter, Gauge, Histogram
|
|
13
|
+
|
|
14
|
+
from psycache.typing import (
|
|
15
|
+
CacheCleanupSpan,
|
|
16
|
+
CacheFlushSpan,
|
|
17
|
+
CacheGetSpan,
|
|
18
|
+
CachePutSpan,
|
|
19
|
+
CacheRemoveSpan,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_LABEL_NAMES = ["span_name"]
|
|
24
|
+
|
|
25
|
+
CACHE_HITS = Counter(
|
|
26
|
+
"psycache_hits_total",
|
|
27
|
+
"Number of cache hits",
|
|
28
|
+
labelnames=_LABEL_NAMES,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
CACHE_MISSES = Counter(
|
|
32
|
+
"psycache_misses_total",
|
|
33
|
+
"Number of cache misses",
|
|
34
|
+
labelnames=_LABEL_NAMES,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
CACHE_GET_DURATION = Histogram(
|
|
38
|
+
"psycache_get_duration_seconds",
|
|
39
|
+
"Time taken for cache get operations",
|
|
40
|
+
labelnames=_LABEL_NAMES,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
CACHE_PUT_DURATION = Histogram(
|
|
44
|
+
"psycache_put_duration_seconds",
|
|
45
|
+
"Time taken for cache put operations",
|
|
46
|
+
labelnames=_LABEL_NAMES,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
CACHE_REMOVE_DURATION = Histogram(
|
|
50
|
+
"psycache_remove_duration_seconds",
|
|
51
|
+
"Time taken for cache remove operations",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
CACHE_FLUSH_DURATION = Histogram(
|
|
55
|
+
"psycache_flush_duration_seconds",
|
|
56
|
+
"Time taken for cache flush operations",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
CACHE_ITEM_SIZE = Histogram(
|
|
60
|
+
"psycache_item_size_bytes",
|
|
61
|
+
"Size of cache items in bytes",
|
|
62
|
+
labelnames=_LABEL_NAMES,
|
|
63
|
+
buckets=[64, 256, 1024, 4096, 16384, 65536, 262144, 1048576],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
CACHE_FLUSHED_ENTRIES = Histogram(
|
|
67
|
+
"psycache_flushed_entries",
|
|
68
|
+
"Number of entries flushed per flush operation",
|
|
69
|
+
buckets=[0, 1, 5, 10, 25, 50, 100, 250, 500, 1000],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
CACHE_CLEANUP_LAST_RUN = Gauge(
|
|
73
|
+
"psycache_cleanup_last_run_timestamp_seconds",
|
|
74
|
+
"Timestamp of the last expired-entry cleanup run",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
CACHE_CLEANUP_DELETED = Gauge(
|
|
78
|
+
"psycache_cleanup_deleted_entries",
|
|
79
|
+
"Number of expired entries deleted in the last cleanup run",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _PrometheusCacheGetSpan:
|
|
84
|
+
def __init__(self, span_name: str) -> None:
|
|
85
|
+
self._span_name = span_name
|
|
86
|
+
|
|
87
|
+
def record_cache_hit(self, item_size: int) -> None:
|
|
88
|
+
CACHE_HITS.labels(span_name=self._span_name).inc()
|
|
89
|
+
CACHE_ITEM_SIZE.labels(span_name=self._span_name).observe(item_size)
|
|
90
|
+
|
|
91
|
+
def record_cache_miss(self) -> None:
|
|
92
|
+
CACHE_MISSES.labels(span_name=self._span_name).inc()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _PrometheusCachePutSpan:
|
|
96
|
+
def __init__(self, span_name: str) -> None:
|
|
97
|
+
self._span_name = span_name
|
|
98
|
+
|
|
99
|
+
def record_put(self, item_size: int) -> None:
|
|
100
|
+
CACHE_ITEM_SIZE.labels(span_name=self._span_name).observe(item_size)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _PrometheusCacheRemoveSpan:
|
|
104
|
+
def record_removed(self) -> None:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _PrometheusCacheCleanupSpan:
|
|
109
|
+
def record_cleanup(self, num_deleted: int) -> None:
|
|
110
|
+
CACHE_CLEANUP_LAST_RUN.set_to_current_time()
|
|
111
|
+
CACHE_CLEANUP_DELETED.set(num_deleted)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _PrometheusCacheFlushSpan:
|
|
115
|
+
def record_flush(self, num_flushed: int) -> None:
|
|
116
|
+
CACHE_FLUSHED_ENTRIES.observe(num_flushed)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PrometheusInstrumentation:
|
|
120
|
+
"""
|
|
121
|
+
Prometheus-backed instrumentation for cache operations.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
@contextmanager
|
|
125
|
+
def start_cache_get_span(
|
|
126
|
+
self, key: str, name: str | None
|
|
127
|
+
) -> Iterator[CacheGetSpan]:
|
|
128
|
+
span_name = name or ""
|
|
129
|
+
with CACHE_GET_DURATION.labels(span_name=span_name).time():
|
|
130
|
+
yield _PrometheusCacheGetSpan(span_name)
|
|
131
|
+
|
|
132
|
+
@contextmanager
|
|
133
|
+
def start_cache_put_span(
|
|
134
|
+
self, key: str, name: str | None
|
|
135
|
+
) -> Iterator[CachePutSpan]:
|
|
136
|
+
span_name = name or ""
|
|
137
|
+
with CACHE_PUT_DURATION.labels(span_name=span_name).time():
|
|
138
|
+
yield _PrometheusCachePutSpan(span_name)
|
|
139
|
+
|
|
140
|
+
@contextmanager
|
|
141
|
+
def start_cache_remove_span(self, key: str) -> Iterator[CacheRemoveSpan]:
|
|
142
|
+
with CACHE_REMOVE_DURATION.time():
|
|
143
|
+
yield _PrometheusCacheRemoveSpan()
|
|
144
|
+
|
|
145
|
+
@contextmanager
|
|
146
|
+
def start_cache_cleanup_span(
|
|
147
|
+
self,
|
|
148
|
+
) -> Iterator[CacheCleanupSpan]:
|
|
149
|
+
yield _PrometheusCacheCleanupSpan()
|
|
150
|
+
|
|
151
|
+
@contextmanager
|
|
152
|
+
def start_cache_flush_span(
|
|
153
|
+
self,
|
|
154
|
+
) -> Iterator[CacheFlushSpan]:
|
|
155
|
+
with CACHE_FLUSH_DURATION.time():
|
|
156
|
+
yield _PrometheusCacheFlushSpan()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Sentry-backed cache instrumentation.
|
|
7
|
+
|
|
8
|
+
See also:
|
|
9
|
+
|
|
10
|
+
- <https://docs.sentry.io/platforms/python/tracing/instrumentation/custom-instrumentation/caches-module/>
|
|
11
|
+
- <https://develop.sentry.dev/sdk/telemetry/traces/modules/caches/>
|
|
12
|
+
|
|
13
|
+
Span instances are not frozen because they're instantiated a lot so we go for
|
|
14
|
+
performance.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
|
|
20
|
+
import attrs
|
|
21
|
+
import sentry_sdk
|
|
22
|
+
|
|
23
|
+
from sentry_sdk.tracing import Span
|
|
24
|
+
|
|
25
|
+
from psycache.instrumentation import NoopAnySpan
|
|
26
|
+
from psycache.typing import (
|
|
27
|
+
CacheCleanupSpan,
|
|
28
|
+
CacheFlushSpan,
|
|
29
|
+
CacheGetSpan,
|
|
30
|
+
CachePutSpan,
|
|
31
|
+
CacheRemoveSpan,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@attrs.define
|
|
36
|
+
class _SentryCacheGetSpan:
|
|
37
|
+
"""
|
|
38
|
+
Sentry-backed span for cache get operations.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_key: str
|
|
42
|
+
_span: Span
|
|
43
|
+
|
|
44
|
+
def record_cache_hit(self, item_size: int) -> None:
|
|
45
|
+
self._span.set_data("cache.hit", True)
|
|
46
|
+
self._span.set_data("cache.item_size", item_size)
|
|
47
|
+
self._span.set_data("cache.success", True)
|
|
48
|
+
|
|
49
|
+
def record_cache_miss(self) -> None:
|
|
50
|
+
self._span.set_data("cache.hit", False)
|
|
51
|
+
self._span.set_data("cache.success", True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@attrs.define
|
|
55
|
+
class _SentryCachePutSpan:
|
|
56
|
+
"""
|
|
57
|
+
Sentry-backed span for cache put operations.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_key: str
|
|
61
|
+
_span: Span
|
|
62
|
+
|
|
63
|
+
def record_put(self, item_size: int) -> None:
|
|
64
|
+
self._span.set_data("cache.item_size", item_size)
|
|
65
|
+
self._span.set_data("cache.success", True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@attrs.define
|
|
69
|
+
class _SentryCacheRemoveSpan:
|
|
70
|
+
"""
|
|
71
|
+
Sentry-backed span for cache removal operations.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
_key: str
|
|
75
|
+
_span: Span
|
|
76
|
+
|
|
77
|
+
def record_removed(self) -> None:
|
|
78
|
+
self._span.set_data("cache.success", True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@attrs.define
|
|
82
|
+
class _SentryCacheFlushSpan:
|
|
83
|
+
"""
|
|
84
|
+
Sentry-backed span for cache flush operations.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
_span: Span
|
|
88
|
+
|
|
89
|
+
def record_flush(self, num_flushed: int) -> None:
|
|
90
|
+
self._span.set_data("cache.num_flushed", num_flushed)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SentryInstrumentation:
|
|
94
|
+
"""
|
|
95
|
+
Sentry-backed instrumentation for cache operations.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
@contextmanager
|
|
99
|
+
def start_cache_get_span(
|
|
100
|
+
self, key: str, name: str | None
|
|
101
|
+
) -> Iterator[CacheGetSpan]:
|
|
102
|
+
with sentry_sdk.start_span(
|
|
103
|
+
op="cache.get", name=name or "psycache get"
|
|
104
|
+
) as span:
|
|
105
|
+
span.set_data("cache.key", [key])
|
|
106
|
+
|
|
107
|
+
yield _SentryCacheGetSpan(key, span)
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def start_cache_put_span(
|
|
111
|
+
self, key: str, name: str | None
|
|
112
|
+
) -> Iterator[CachePutSpan]:
|
|
113
|
+
with sentry_sdk.start_span(
|
|
114
|
+
op="cache.put", name=name or "psycache put"
|
|
115
|
+
) as span:
|
|
116
|
+
span.set_data("cache.key", [key])
|
|
117
|
+
|
|
118
|
+
yield _SentryCachePutSpan(key, span)
|
|
119
|
+
|
|
120
|
+
@contextmanager
|
|
121
|
+
def start_cache_remove_span(self, key: str) -> Iterator[CacheRemoveSpan]:
|
|
122
|
+
with sentry_sdk.start_span(
|
|
123
|
+
op="cache.remove", name="psycache remove"
|
|
124
|
+
) as span:
|
|
125
|
+
span.set_data("cache.key", [key])
|
|
126
|
+
|
|
127
|
+
yield _SentryCacheRemoveSpan(key, span)
|
|
128
|
+
|
|
129
|
+
@contextmanager
|
|
130
|
+
def start_cache_cleanup_span(
|
|
131
|
+
self,
|
|
132
|
+
) -> Iterator[CacheCleanupSpan]:
|
|
133
|
+
yield NoopAnySpan()
|
|
134
|
+
|
|
135
|
+
@contextmanager
|
|
136
|
+
def start_cache_flush_span(
|
|
137
|
+
self,
|
|
138
|
+
) -> Iterator[CacheFlushSpan]:
|
|
139
|
+
with sentry_sdk.start_span(
|
|
140
|
+
op="cache.flush", name="psycache flush"
|
|
141
|
+
) as span:
|
|
142
|
+
yield _SentryCacheFlushSpan(span)
|
psycache/psycopg_pool.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Integration with `psycopg_pool`: <https://www.psycopg.org/psycopg3/docs/api/pool.html>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Iterator
|
|
10
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import attrs
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
16
|
+
from psycopg_pool import AsyncConnectionPool, ConnectionPool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@attrs.frozen
|
|
20
|
+
class PsycopgCachePool:
|
|
21
|
+
"""
|
|
22
|
+
A cache pool based on `psycopg_pool.ConnectionPool`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_pool: ConnectionPool[Any] = attrs.field(alias="pool")
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def connect(self) -> Iterator[psycopg.Connection]:
|
|
29
|
+
with self._pool.connection() as conn:
|
|
30
|
+
autocommit = conn.autocommit
|
|
31
|
+
conn.autocommit = True
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
yield conn
|
|
35
|
+
finally:
|
|
36
|
+
conn.autocommit = autocommit
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@attrs.frozen
|
|
40
|
+
class AsyncPsycopgCachePool:
|
|
41
|
+
"""
|
|
42
|
+
A cache pool based on `psycopg_pool.AsyncConnectionPool`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_pool: AsyncConnectionPool[Any] = attrs.field(alias="pool")
|
|
46
|
+
|
|
47
|
+
@asynccontextmanager
|
|
48
|
+
async def connect(self) -> AsyncIterator[psycopg.AsyncConnection]:
|
|
49
|
+
async with self._pool.connection() as conn:
|
|
50
|
+
autocommit = conn.autocommit
|
|
51
|
+
await conn.set_autocommit(True)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
yield conn
|
|
55
|
+
finally:
|
|
56
|
+
await conn.set_autocommit(autocommit)
|
psycache/py.typed
ADDED
|
File without changes
|
psycache/sqlalchemy.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Integration with SQLAlchemy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Iterator
|
|
10
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
11
|
+
|
|
12
|
+
import attrs
|
|
13
|
+
import psycopg
|
|
14
|
+
|
|
15
|
+
from sqlalchemy.engine import Engine
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@attrs.frozen
|
|
20
|
+
class SQLAlchemyCachePool:
|
|
21
|
+
"""
|
|
22
|
+
A cache pool based on a SQLAlchemy engine.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
engine: Engine
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def connect(self) -> Iterator[psycopg.Connection]:
|
|
29
|
+
with self.engine.connect().execution_options(
|
|
30
|
+
isolation_level="AUTOCOMMIT"
|
|
31
|
+
) as conn:
|
|
32
|
+
yield conn.connection # type: ignore[misc] # ty: ignore[invalid-yield]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@attrs.frozen
|
|
36
|
+
class AsyncSQLAlchemyCachePool:
|
|
37
|
+
"""
|
|
38
|
+
A cache pool based on a SQLAlchemy async engine.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
engine: AsyncEngine
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def connect(self) -> AsyncIterator[psycopg.AsyncConnection]:
|
|
45
|
+
async with self.engine.connect() as sa_conn:
|
|
46
|
+
conn = await sa_conn.execution_options(
|
|
47
|
+
isolation_level="AUTOCOMMIT"
|
|
48
|
+
)
|
|
49
|
+
raw = await conn.get_raw_connection()
|
|
50
|
+
|
|
51
|
+
yield raw.driver_connection # type: ignore[misc] # ty: ignore[invalid-yield]
|