altcodepro-polydb-python 2.5.4__py3-none-any.whl → 2.5.7__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.
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/METADATA +69 -20
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/RECORD +28 -21
- polydb/__init__.py +1 -1
- polydb/adapters/AWSSecretsManagerAdapter.py +72 -0
- polydb/adapters/AzureKeyVaultAdapter.py +65 -0
- polydb/adapters/AzureQueueAdapter.py +82 -4
- polydb/adapters/DynamoDBAdapter.py +31 -16
- polydb/adapters/FirestoreAdapter.py +30 -13
- polydb/adapters/GCPPubSubAdapter.py +46 -0
- polydb/adapters/GCPSecretManagerAdapter.py +79 -0
- polydb/adapters/KafkaQueueAdapter.py +332 -0
- polydb/adapters/PostgreSQLAdapter.py +114 -6
- polydb/adapters/RabbitMQAdapter.py +465 -0
- polydb/adapters/SQSAdapter.py +60 -0
- polydb/adapters/VaultAdapter.py +75 -0
- polydb/adapters/VercelKVAdapter.py +38 -24
- polydb/adapters/VercelQueueAdapter.py +30 -2
- polydb/base/QueueAdapter.py +123 -1
- polydb/base/SecretsAdapter.py +30 -0
- polydb/cache.py +90 -0
- polydb/cloudDatabaseFactory.py +129 -15
- polydb/databaseFactory.py +262 -60
- polydb/errors.py +6 -0
- polydb/models.py +77 -0
- polydb/retry.py +7 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/top_level.txt +0 -0
|
@@ -43,6 +43,23 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
43
43
|
if self.kv_url.startswith("redis://"):
|
|
44
44
|
self._redis = redis.from_url(self.kv_url, decode_responses=True)
|
|
45
45
|
|
|
46
|
+
def _table_name(self, model: type) -> str:
|
|
47
|
+
"""Same convention DynamoDBAdapter/_table_name and
|
|
48
|
+
FirestoreAdapter/_collection_name already use -- but here it's not
|
|
49
|
+
optional. Unlike DynamoDB (a real per-model table) or Firestore (a
|
|
50
|
+
real per-model collection), Vercel KV/Redis is one flat keyspace: a
|
|
51
|
+
key that was just "{pk}:{rk}" (tenant_id:id) let two different
|
|
52
|
+
models sharing a tenant_id and, coincidentally, the same id
|
|
53
|
+
collide on the exact same physical key -- one model's create
|
|
54
|
+
silently overwriting an unrelated model's row. Reproduced directly:
|
|
55
|
+
a "notes" row and a "widgets" row both keyed "tenant-a:x1" and the
|
|
56
|
+
second create clobbered the first. Folding the table name into the
|
|
57
|
+
key is the fix; every _*_raw method below must use this, not the
|
|
58
|
+
bare "{pk}:{rk}" shape.
|
|
59
|
+
"""
|
|
60
|
+
meta = getattr(model, "__polydb__", {})
|
|
61
|
+
return meta.get("table") or meta.get("collection") or model.__name__.lower()
|
|
62
|
+
|
|
46
63
|
# ------------------------------------------------------------------
|
|
47
64
|
# PUT
|
|
48
65
|
# ------------------------------------------------------------------
|
|
@@ -51,12 +68,20 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
51
68
|
def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
|
|
52
69
|
try:
|
|
53
70
|
|
|
54
|
-
key = f"{pk}:{rk}"
|
|
71
|
+
key = f"{self._table_name(model)}:{pk}:{rk}"
|
|
55
72
|
|
|
56
73
|
payload = dict(data)
|
|
57
74
|
payload["_pk"] = pk
|
|
58
75
|
payload["_rk"] = rk
|
|
59
|
-
|
|
76
|
+
# NOTE: "id" is intentionally left as whatever the caller supplied
|
|
77
|
+
# (already present via `dict(data)` above). NoSQLKVAdapter._get_pk_rk
|
|
78
|
+
# derives `rk` from data.get(rk_field, ...) with rk_field defaulting
|
|
79
|
+
# to "id" -- i.e. the write-side convention is that a model's "id"
|
|
80
|
+
# field means "row key", not partition key. Overwriting it with
|
|
81
|
+
# `pk` here corrupted the caller's own id field and made it
|
|
82
|
+
# inconsistent with the read-side lookups below, which key on the
|
|
83
|
+
# record's real "id" value rather than the partition key.
|
|
84
|
+
payload.setdefault("id", rk)
|
|
60
85
|
|
|
61
86
|
value = json.dumps(payload, default=json_safe)
|
|
62
87
|
|
|
@@ -89,7 +114,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
89
114
|
|
|
90
115
|
try:
|
|
91
116
|
|
|
92
|
-
key = f"{pk}:{rk}"
|
|
117
|
+
key = f"{self._table_name(model)}:{pk}:{rk}"
|
|
93
118
|
|
|
94
119
|
# LOCAL REDIS
|
|
95
120
|
if self._redis:
|
|
@@ -100,7 +125,6 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
100
125
|
return None
|
|
101
126
|
|
|
102
127
|
obj = json.loads(value)
|
|
103
|
-
obj.setdefault("id", obj.get("_pk"))
|
|
104
128
|
return obj
|
|
105
129
|
|
|
106
130
|
# REST API
|
|
@@ -121,7 +145,6 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
121
145
|
return None
|
|
122
146
|
|
|
123
147
|
obj = json.loads(result)
|
|
124
|
-
obj.setdefault("id", obj.get("_pk"))
|
|
125
148
|
|
|
126
149
|
return obj
|
|
127
150
|
|
|
@@ -143,11 +166,12 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
143
166
|
try:
|
|
144
167
|
|
|
145
168
|
results: List[JsonDict] = []
|
|
169
|
+
table = self._table_name(model)
|
|
146
170
|
|
|
147
171
|
# LOCAL REDIS
|
|
148
172
|
if self._redis:
|
|
149
173
|
|
|
150
|
-
for key in self._redis.scan_iter("
|
|
174
|
+
for key in self._redis.scan_iter(f"{table}:*"):
|
|
151
175
|
|
|
152
176
|
value: Any = self._redis.get(key)
|
|
153
177
|
|
|
@@ -160,17 +184,11 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
160
184
|
|
|
161
185
|
for k, v in filters.items():
|
|
162
186
|
|
|
163
|
-
if k
|
|
164
|
-
if obj.get("_pk") != v:
|
|
165
|
-
match = False
|
|
166
|
-
break
|
|
167
|
-
|
|
168
|
-
elif obj.get(k) != v:
|
|
187
|
+
if obj.get(k) != v:
|
|
169
188
|
match = False
|
|
170
189
|
break
|
|
171
190
|
|
|
172
191
|
if match:
|
|
173
|
-
obj.setdefault("id", obj.get("_pk"))
|
|
174
192
|
results.append(obj)
|
|
175
193
|
|
|
176
194
|
if limit and len(results) >= limit:
|
|
@@ -182,7 +200,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
182
200
|
import requests
|
|
183
201
|
|
|
184
202
|
resp = requests.get(
|
|
185
|
-
f"{self.kv_url}/keys
|
|
203
|
+
f"{self.kv_url}/keys/{table}:*",
|
|
186
204
|
headers={"Authorization": f"Bearer {self.kv_token}"},
|
|
187
205
|
timeout=self.timeout,
|
|
188
206
|
)
|
|
@@ -217,17 +235,11 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
217
235
|
|
|
218
236
|
for k, v in filters.items():
|
|
219
237
|
|
|
220
|
-
if k
|
|
221
|
-
if obj.get("_pk") != v:
|
|
222
|
-
match = False
|
|
223
|
-
break
|
|
224
|
-
|
|
225
|
-
elif obj.get(k) != v:
|
|
238
|
+
if obj.get(k) != v:
|
|
226
239
|
match = False
|
|
227
240
|
break
|
|
228
241
|
|
|
229
242
|
if match:
|
|
230
|
-
obj.setdefault("id", obj.get("_pk"))
|
|
231
243
|
results.append(obj)
|
|
232
244
|
|
|
233
245
|
return results
|
|
@@ -250,7 +262,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
250
262
|
|
|
251
263
|
try:
|
|
252
264
|
|
|
253
|
-
key = f"{pk}:{rk}"
|
|
265
|
+
key = f"{self._table_name(model)}:{pk}:{rk}"
|
|
254
266
|
|
|
255
267
|
# LOCAL REDIS
|
|
256
268
|
if self._redis:
|
|
@@ -260,7 +272,9 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
260
272
|
|
|
261
273
|
self._redis.delete(key)
|
|
262
274
|
|
|
263
|
-
|
|
275
|
+
# "id" means row key throughout this adapter (see _put_raw) --
|
|
276
|
+
# return rk, not pk, for consistency.
|
|
277
|
+
return {"id": rk}
|
|
264
278
|
|
|
265
279
|
# REST API
|
|
266
280
|
import requests
|
|
@@ -280,7 +294,7 @@ class VercelKVAdapter(NoSQLKVAdapter):
|
|
|
280
294
|
timeout=self.timeout,
|
|
281
295
|
).raise_for_status()
|
|
282
296
|
|
|
283
|
-
return {"id":
|
|
297
|
+
return {"id": rk}
|
|
284
298
|
|
|
285
299
|
except DatabaseError:
|
|
286
300
|
raise
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import json
|
|
3
3
|
import requests
|
|
4
|
-
|
|
4
|
+
import redis
|
|
5
|
+
from typing import Dict, Any, List, Optional
|
|
5
6
|
|
|
6
7
|
from ..base.QueueAdapter import QueueAdapter
|
|
7
8
|
from ..errors import QueueError
|
|
@@ -14,12 +15,25 @@ class VercelQueueAdapter:
|
|
|
14
15
|
self.url = url or os.getenv("KV_REST_API_URL")
|
|
15
16
|
self.token = token or os.getenv("KV_REST_API_TOKEN")
|
|
16
17
|
|
|
18
|
+
# Same local-testability convention VercelKVAdapter already
|
|
19
|
+
# established: a plain redis:// URL means "use local Redis
|
|
20
|
+
# directly," no cloud account or REST endpoint needed. Redis
|
|
21
|
+
# Streams (XADD/XRANGE/XDEL) are the same primitive the REST
|
|
22
|
+
# mode already speaks (see send/receive below), so this isn't a
|
|
23
|
+
# different queue model locally vs. in production, just a
|
|
24
|
+
# different transport to the same Redis Streams operations.
|
|
25
|
+
self._redis: Optional["redis.Redis"] = None
|
|
26
|
+
if self.url and self.url.startswith("redis://"):
|
|
27
|
+
self._redis = redis.from_url(self.url, decode_responses=True)
|
|
28
|
+
|
|
17
29
|
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
18
30
|
def send(self, message: Dict[str, Any], queue_name: str = "default") -> str:
|
|
19
31
|
try:
|
|
20
|
-
|
|
21
32
|
payload = json.dumps(message)
|
|
22
33
|
|
|
34
|
+
if self._redis:
|
|
35
|
+
return self._redis.xadd(queue_name, {"payload": payload})
|
|
36
|
+
|
|
23
37
|
r = requests.post(
|
|
24
38
|
f"{self.url}/xadd/{queue_name}",
|
|
25
39
|
headers={"Authorization": f"Bearer {self.token}"},
|
|
@@ -37,6 +51,14 @@ class VercelQueueAdapter:
|
|
|
37
51
|
def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict]:
|
|
38
52
|
|
|
39
53
|
try:
|
|
54
|
+
if self._redis:
|
|
55
|
+
entries = self._redis.xrange(queue_name, count=max_messages)
|
|
56
|
+
messages = []
|
|
57
|
+
for entry_id, fields in entries:
|
|
58
|
+
data = json.loads(fields["payload"])
|
|
59
|
+
data["_id"] = entry_id
|
|
60
|
+
messages.append(data)
|
|
61
|
+
return messages
|
|
40
62
|
|
|
41
63
|
r = requests.get(
|
|
42
64
|
f"{self.url}/xrange/{queue_name}/-/{max_messages}",
|
|
@@ -60,6 +82,12 @@ class VercelQueueAdapter:
|
|
|
60
82
|
raise QueueError(f"Vercel queue receive failed: {e}")
|
|
61
83
|
|
|
62
84
|
def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
|
|
85
|
+
if self._redis:
|
|
86
|
+
# Real deletion locally -- unlike the REST-mode no-op below
|
|
87
|
+
# (Redis Streams via the Vercel KV REST API has no XDEL
|
|
88
|
+
# endpoint exposed, same limitation ack() below documents for
|
|
89
|
+
# explicit ACK), local Redis has the primitive natively.
|
|
90
|
+
return bool(self._redis.xdel(queue_name, message_id))
|
|
63
91
|
return True
|
|
64
92
|
|
|
65
93
|
# ---------------------------------------------------------
|
polydb/base/QueueAdapter.py
CHANGED
|
@@ -2,7 +2,7 @@ from polydb.utils import setup_logger
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
-
from typing import Any, Dict, List
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class QueueAdapter(ABC):
|
|
@@ -25,3 +25,125 @@ class QueueAdapter(ABC):
|
|
|
25
25
|
def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
|
|
26
26
|
"""Delete message from queue"""
|
|
27
27
|
pass
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
# Extended queue management -- regular (non-abstract) methods, not
|
|
31
|
+
# `@abstractmethod`, on purpose. `send`/`receive`/`delete` above are
|
|
32
|
+
# the original contract every adapter (SQS/Azure/GCP/Vercel/
|
|
33
|
+
# Blockchain/Kafka/RabbitMQ) already implements; making the methods
|
|
34
|
+
# below abstract too would force every one of those subclasses to
|
|
35
|
+
# grow a stub the instant this file changes, even the ones nobody has
|
|
36
|
+
# actually implemented these for yet. Matches the same pattern
|
|
37
|
+
# `NoSQLKVAdapter`/`SecretsAdapter` already use for their own
|
|
38
|
+
# not-yet-universal capabilities (a bare `raise NotImplementedError`
|
|
39
|
+
# in the base, overridden only where a subclass genuinely supports
|
|
40
|
+
# it) -- a subclass that doesn't override one of these raises here,
|
|
41
|
+
# loudly and by name, instead of silently doing nothing.
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def nack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
45
|
+
"""Negative-acknowledge: put the message back for redelivery
|
|
46
|
+
immediately, without waiting out any natural visibility/
|
|
47
|
+
redelivery timeout the backend might otherwise enforce."""
|
|
48
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement nack()")
|
|
49
|
+
|
|
50
|
+
def purge(self, queue_name: str = "default") -> int:
|
|
51
|
+
"""Delete every message currently in the queue. Returns the
|
|
52
|
+
number purged where the backend reports one; adapters that
|
|
53
|
+
implement this and genuinely cannot get a count from the backend
|
|
54
|
+
document their own sentinel return value in their override --
|
|
55
|
+
this base default never guesses one for an adapter nobody has
|
|
56
|
+
implemented purge() for yet."""
|
|
57
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement purge()")
|
|
58
|
+
|
|
59
|
+
def declare(
|
|
60
|
+
self,
|
|
61
|
+
queue_name: str = "default",
|
|
62
|
+
*,
|
|
63
|
+
durable: bool = True,
|
|
64
|
+
dead_letter_queue: Optional[str] = None,
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""Explicit queue provisioning ahead of first use -- distinct
|
|
67
|
+
from the auto-create-on-first-send/receive some adapters already
|
|
68
|
+
do implicitly. When `dead_letter_queue` is given, wires up
|
|
69
|
+
dead-lettering to a second queue by that name (adapter-specific
|
|
70
|
+
mechanism; see each override for what "dead-lettering" actually
|
|
71
|
+
means on that backend)."""
|
|
72
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement declare()")
|
|
73
|
+
|
|
74
|
+
def status(self, queue_name: str = "default") -> Dict[str, Any]:
|
|
75
|
+
"""At minimum `{"message_count": int}`; an override may add other
|
|
76
|
+
fields the backend genuinely exposes."""
|
|
77
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement status()")
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# extend / delay / cancel -- the same "regular method, not abstract,
|
|
81
|
+
# base raises loudly by name" shape as nack/purge/declare/status
|
|
82
|
+
# above, for the identical reason: adding these to every existing
|
|
83
|
+
# subclass at once the moment this file changes would be a false
|
|
84
|
+
# claim of support nobody actually verified. Each is real ONLY where
|
|
85
|
+
# a genuine backend primitive exists for it -- checked per adapter,
|
|
86
|
+
# per method, against the real installed SDK, not assumed uniform:
|
|
87
|
+
# - extend(): SQS (ChangeMessageVisibility), Azure Queue
|
|
88
|
+
# (UpdateMessage with no content= change), GCP Pub/Sub
|
|
89
|
+
# (ModifyAckDeadline) all have a REAL "keep this ALREADY-RECEIVED
|
|
90
|
+
# message invisible a while longer" primitive. RabbitMQ/Kafka do
|
|
91
|
+
# not -- AMQP's consumer model has no per-message renewable
|
|
92
|
+
# visibility timer at all (ack/nack/reject only), and Kafka's
|
|
93
|
+
# offset-based model has no per-message delivery state to extend
|
|
94
|
+
# either -- so neither adapter overrides this.
|
|
95
|
+
# - delay(): SQS (SendMessage's own DelaySeconds, real, capped at
|
|
96
|
+
# 900s by SQS itself), Azure Queue (send_message's own
|
|
97
|
+
# visibility_timeout param), and RabbitMQ (a real, plugin-free
|
|
98
|
+
# AMQP pattern: publish to a per-duration queue declared with
|
|
99
|
+
# x-message-ttl + a dead-letter-exchange pointing at the real
|
|
100
|
+
# queue_name, so the message lands there automatically once the
|
|
101
|
+
# TTL elapses) all have a real "don't make this NEW message
|
|
102
|
+
# visible until N seconds from now" primitive. GCP Pub/Sub has no
|
|
103
|
+
# such primitive (a published message is deliverable immediately;
|
|
104
|
+
# Cloud Tasks/Scheduler is a genuinely different GCP service, out
|
|
105
|
+
# of scope for a Pub/Sub adapter) and Kafka doesn't either.
|
|
106
|
+
# - cancel(): only meaningful for a message a delay() call has not
|
|
107
|
+
# yet delivered. Azure Queue is real (the id+pop_receipt
|
|
108
|
+
# send_message's own response returns are already enough to
|
|
109
|
+
# delete that exact message before its visibility_timeout
|
|
110
|
+
# elapses). RabbitMQ is real too, via a bounded scan-and-requeue
|
|
111
|
+
# of the one specific per-duration delay queue this message was
|
|
112
|
+
# published to (see RabbitMQAdapter.cancel's own docstring for
|
|
113
|
+
# why this is the honest, bounded shape rather than an unbounded
|
|
114
|
+
# one). SQS has none: a delayed SQS message is not receivable
|
|
115
|
+
# (no ReceiptHandle exists) until its own DelaySeconds elapses,
|
|
116
|
+
# so there is no real API call that can remove it earlier.
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def extend(
|
|
120
|
+
self, ack_id: str, queue_name: str = "default", *, visibility_timeout: int = 30
|
|
121
|
+
) -> bool:
|
|
122
|
+
"""Extend how long an already-received, not-yet-acked message
|
|
123
|
+
stays invisible to other consumers, without acknowledging or
|
|
124
|
+
requeuing it -- the redelivery-window equivalent of "I'm still
|
|
125
|
+
working on this, don't hand it to anyone else yet." `ack_id` is
|
|
126
|
+
the same receipt/handle `receive()` returned for this message."""
|
|
127
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement extend()")
|
|
128
|
+
|
|
129
|
+
def delay(
|
|
130
|
+
self, message: Dict[str, Any], queue_name: str = "default", *, delay_seconds: int = 0
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Send a NEW message that will not become visible/deliverable
|
|
133
|
+
until `delay_seconds` from now. Returns an id `cancel()` can use
|
|
134
|
+
to remove it again before that happens -- not necessarily the
|
|
135
|
+
same string shape `send()` returns, since some backends (Azure)
|
|
136
|
+
need more than a bare message id to cancel a not-yet-visible
|
|
137
|
+
message."""
|
|
138
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement delay()")
|
|
139
|
+
|
|
140
|
+
def cancel(self, message_id: str, queue_name: str = "default") -> bool:
|
|
141
|
+
"""Cancel a still-delayed message (one a prior `delay()` call has
|
|
142
|
+
not yet made visible) before it is ever delivered. `message_id`
|
|
143
|
+
is whatever `delay()` returned for it. Returns whether a
|
|
144
|
+
matching still-pending message was actually found and removed --
|
|
145
|
+
`False`, not an exception, when it already fired or never
|
|
146
|
+
existed, the same "unknown id is a no-op, not an error"
|
|
147
|
+
convention `RabbitMQAdapter._ack_delivery` already establishes
|
|
148
|
+
for a different method."""
|
|
149
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not implement cancel()")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from ..utils import setup_logger
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SecretsAdapter(ABC):
|
|
7
|
+
"""Base class for cloud secret managers (Key Vault / Secrets Manager /
|
|
8
|
+
Secret Manager / Vault) -- same shape as the other base adapters
|
|
9
|
+
(ObjectStorageAdapter, QueueAdapter, ...), dispatched the same way via
|
|
10
|
+
CloudDatabaseFactory.get_secrets()."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.logger = setup_logger(self.__class__.__name__)
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
17
|
+
"""Return the current value of a secret, or None if it doesn't exist."""
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def set_secret(self, key: str, value: str) -> None:
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def delete_secret(self, key: str) -> bool:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def list_secrets(self, prefix: str = "") -> list[str]:
|
|
30
|
+
raise NotImplementedError
|
polydb/cache.py
CHANGED
|
@@ -204,6 +204,96 @@ class RedisCacheEngine:
|
|
|
204
204
|
except Exception:
|
|
205
205
|
pass
|
|
206
206
|
|
|
207
|
+
# ---------------------------------------------------------
|
|
208
|
+
# Generic raw KV operations -- for a caller that wants a plain
|
|
209
|
+
# Redis get/set/incr under an explicit key it picked itself, not the
|
|
210
|
+
# query-result caching get()/set() above (which hash a query dict
|
|
211
|
+
# into the key). Reuses the same _make_raw_key (no hashing) as the
|
|
212
|
+
# zset methods above, so a raw KV key and a zset key never collide
|
|
213
|
+
# as long as the caller doesn't reuse the same `key` for both.
|
|
214
|
+
#
|
|
215
|
+
# Unlike get()/set()/invalidate() above -- which are an *automatic*,
|
|
216
|
+
# best-effort read-through cache where a Redis hiccup or missing
|
|
217
|
+
# backend should never break the underlying query -- these raw
|
|
218
|
+
# methods back a caller's own explicit business logic (e.g. a
|
|
219
|
+
# workflow step doing rate limiting or a distributed lock via
|
|
220
|
+
# incrby()). Silently returning None/0/False there would hide a real
|
|
221
|
+
# misconfiguration (no REDIS_CACHE_URL set) behind wrong-looking
|
|
222
|
+
# business results instead of a clear error, so these raise
|
|
223
|
+
# CacheError when no client is configured or the operation itself
|
|
224
|
+
# fails, rather than swallowing the exception.
|
|
225
|
+
# ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def get_raw(self, model: str, key: str) -> Optional[Any]:
|
|
228
|
+
from .errors import CacheError
|
|
229
|
+
|
|
230
|
+
if not self._client:
|
|
231
|
+
raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
|
|
232
|
+
redis_key = self._make_raw_key(model, key)
|
|
233
|
+
try:
|
|
234
|
+
data = self._client.get(redis_key)
|
|
235
|
+
if data is None:
|
|
236
|
+
return None
|
|
237
|
+
if isinstance(data, bytes):
|
|
238
|
+
data = data.decode("utf-8")
|
|
239
|
+
return json.loads(data)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
raise CacheError(f"Redis get failed: {e}")
|
|
242
|
+
|
|
243
|
+
def set_raw(self, model: str, key: str, value: Any, ttl: Optional[int] = None) -> bool:
|
|
244
|
+
from .errors import CacheError
|
|
245
|
+
|
|
246
|
+
if not self._client:
|
|
247
|
+
raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
|
|
248
|
+
redis_key = self._make_raw_key(model, key)
|
|
249
|
+
data = json.dumps(value, default=json_safe)
|
|
250
|
+
try:
|
|
251
|
+
if ttl:
|
|
252
|
+
self._client.setex(redis_key, ttl, data)
|
|
253
|
+
else:
|
|
254
|
+
self._client.set(redis_key, data)
|
|
255
|
+
return True
|
|
256
|
+
except Exception as e:
|
|
257
|
+
raise CacheError(f"Redis set failed: {e}")
|
|
258
|
+
|
|
259
|
+
def incrby(self, model: str, key: str, amount: int = 1) -> int:
|
|
260
|
+
"""Atomic increment (INCRBY) of a raw key, creating it at 0 first
|
|
261
|
+
if absent -- the primitive a rate-limiter/counter workflow step
|
|
262
|
+
needs, distinct from the zset-based rate limiting above."""
|
|
263
|
+
from .errors import CacheError
|
|
264
|
+
|
|
265
|
+
if not self._client:
|
|
266
|
+
raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
|
|
267
|
+
redis_key = self._make_raw_key(model, key)
|
|
268
|
+
try:
|
|
269
|
+
return int(self._client.incrby(redis_key, amount))
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise CacheError(f"Redis incrby failed: {e}")
|
|
272
|
+
|
|
273
|
+
def exists_raw(self, model: str, key: str) -> bool:
|
|
274
|
+
from .errors import CacheError
|
|
275
|
+
|
|
276
|
+
if not self._client:
|
|
277
|
+
raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
|
|
278
|
+
redis_key = self._make_raw_key(model, key)
|
|
279
|
+
try:
|
|
280
|
+
return bool(self._client.exists(redis_key))
|
|
281
|
+
except Exception as e:
|
|
282
|
+
raise CacheError(f"Redis exists failed: {e}")
|
|
283
|
+
|
|
284
|
+
def ttl_raw(self, model: str, key: str) -> int:
|
|
285
|
+
"""Seconds until expiry; -1 means the key exists with no TTL, -2
|
|
286
|
+
means the key doesn't exist -- standard Redis TTL semantics."""
|
|
287
|
+
from .errors import CacheError
|
|
288
|
+
|
|
289
|
+
if not self._client:
|
|
290
|
+
raise CacheError("No Redis cache backend configured (set REDIS_CACHE_URL/REDIS_URL)")
|
|
291
|
+
redis_key = self._make_raw_key(model, key)
|
|
292
|
+
try:
|
|
293
|
+
return int(self._client.ttl(redis_key))
|
|
294
|
+
except Exception as e:
|
|
295
|
+
raise CacheError(f"Redis ttl failed: {e}")
|
|
296
|
+
|
|
207
297
|
|
|
208
298
|
class CacheWarmer:
|
|
209
299
|
"""Pre-populate cache with frequently accessed data"""
|