altcodepro-polydb-python 2.5.4__py3-none-any.whl → 2.5.6__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.6.dist-info}/METADATA +69 -20
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.6.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 +225 -49
- 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.6.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
# src/polydb/adapters/RabbitMQAdapter.py
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..base.QueueAdapter import QueueAdapter
|
|
9
|
+
from ..errors import ConnectionError, QueueError
|
|
10
|
+
from ..retry import retry
|
|
11
|
+
from ..json_safe import json_safe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RabbitMQAdapter(QueueAdapter):
|
|
15
|
+
"""
|
|
16
|
+
RabbitMQ adapter using the synchronous `pika` client, not `aio-pika` --
|
|
17
|
+
same rationale as KafkaQueueAdapter: every adapter in this codebase is
|
|
18
|
+
synchronous end to end.
|
|
19
|
+
|
|
20
|
+
queue_name IS the AMQP queue name. Queues are declared durable and
|
|
21
|
+
auto-created on first use, matching SQSAdapter's/AzureQueueAdapter's
|
|
22
|
+
"create the underlying resource if it doesn't already exist" behavior.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str = "",
|
|
28
|
+
host: str = "",
|
|
29
|
+
port: int = 0,
|
|
30
|
+
username: str = "",
|
|
31
|
+
password: str = "",
|
|
32
|
+
virtual_host: str = "",
|
|
33
|
+
):
|
|
34
|
+
super().__init__()
|
|
35
|
+
|
|
36
|
+
# An explicit amqp:// URL (or RABBITMQ_URL/AMQP_URL env var) takes
|
|
37
|
+
# priority; otherwise build one from discrete fields/env so callers
|
|
38
|
+
# don't have to hand-assemble a URL just to point at localhost.
|
|
39
|
+
self.url = url or os.getenv("RABBITMQ_URL") or os.getenv("AMQP_URL")
|
|
40
|
+
|
|
41
|
+
if not self.url:
|
|
42
|
+
host = host or os.getenv("RABBITMQ_HOST", "localhost")
|
|
43
|
+
port = port or int(os.getenv("RABBITMQ_PORT", "5672"))
|
|
44
|
+
username = username or os.getenv("RABBITMQ_USER", "guest")
|
|
45
|
+
password = password or os.getenv("RABBITMQ_PASSWORD", "guest")
|
|
46
|
+
virtual_host = virtual_host or os.getenv("RABBITMQ_VHOST", "/")
|
|
47
|
+
vhost_path = virtual_host if virtual_host.startswith("/") else f"/{virtual_host}"
|
|
48
|
+
self.url = f"amqp://{username}:{password}@{host}:{port}{vhost_path}"
|
|
49
|
+
|
|
50
|
+
self._connection: Any = None
|
|
51
|
+
self._channel: Any = None
|
|
52
|
+
self._declared_queues: set = set()
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
# Our own message_id (embedded in AMQP message properties on
|
|
56
|
+
# publish, since basic_publish has no return value to hand one
|
|
57
|
+
# back) -> the AMQP delivery_tag basic_get gave us for it. ack()/
|
|
58
|
+
# delete() look the delivery tag up here so callers never need to
|
|
59
|
+
# know pika's delivery-tag concept exists.
|
|
60
|
+
self._pending: Dict[str, int] = {}
|
|
61
|
+
|
|
62
|
+
self._initialize_connection()
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------
|
|
65
|
+
# Connection management
|
|
66
|
+
# ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _initialize_connection(self) -> None:
|
|
69
|
+
import pika
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._connection is not None and self._connection.is_open:
|
|
74
|
+
return
|
|
75
|
+
self._connection = pika.BlockingConnection(pika.URLParameters(self.url))
|
|
76
|
+
self._channel = self._connection.channel()
|
|
77
|
+
self.logger.info("Initialized RabbitMQ connection")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise ConnectionError(f"RabbitMQ connection failed: {e}")
|
|
80
|
+
|
|
81
|
+
def _ensure_open(self) -> None:
|
|
82
|
+
needs_reconnect = (
|
|
83
|
+
self._connection is None
|
|
84
|
+
or self._connection.is_closed
|
|
85
|
+
or self._channel is None
|
|
86
|
+
or self._channel.is_closed
|
|
87
|
+
)
|
|
88
|
+
if needs_reconnect:
|
|
89
|
+
# A dropped connection also invalidates any queues we thought
|
|
90
|
+
# we'd already declared on the old channel.
|
|
91
|
+
self._declared_queues.clear()
|
|
92
|
+
self._initialize_connection()
|
|
93
|
+
|
|
94
|
+
def _ensure_queue(self, queue_name: str) -> None:
|
|
95
|
+
"""Auto-create-on-first-use path for send()/receive(). Plain
|
|
96
|
+
`durable=True`, no arguments -- if this ran again on a queue
|
|
97
|
+
`declare()` already provisioned WITH dead-letter arguments,
|
|
98
|
+
RabbitMQ would reject it as a channel-level PRECONDITION_FAILED
|
|
99
|
+
(re-declaring an existing queue with different `arguments` is an
|
|
100
|
+
error, not a silent merge). That doesn't happen here because
|
|
101
|
+
`declare()` adds `queue_name` to `_declared_queues` itself after
|
|
102
|
+
declaring it -- the `if queue_name in self._declared_queues:
|
|
103
|
+
return` guard below short-circuits before this method's own
|
|
104
|
+
plain queue_declare ever runs, as long as `declare()` was called
|
|
105
|
+
before the first send()/receive() on that queue. Calling
|
|
106
|
+
`declare()` *after* an implicit auto-create already happened on
|
|
107
|
+
the same name is the one ordering this doesn't protect against --
|
|
108
|
+
the second declare (with arguments) would itself hit
|
|
109
|
+
PRECONDITION_FAILED against the already-durable/no-arguments
|
|
110
|
+
queue this method created first.
|
|
111
|
+
"""
|
|
112
|
+
if queue_name in self._declared_queues:
|
|
113
|
+
return
|
|
114
|
+
self._channel.queue_declare(queue=queue_name, durable=True)
|
|
115
|
+
self._declared_queues.add(queue_name)
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------
|
|
118
|
+
# Queue operations
|
|
119
|
+
# ---------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
122
|
+
def send(self, message: Dict[str, Any], queue_name: str = "default") -> str:
|
|
123
|
+
"""Publish to `queue_name`, returning a message id we generate and
|
|
124
|
+
embed ourselves -- AMQP's basic_publish is fire-and-forget and
|
|
125
|
+
hands back nothing to identify the message with."""
|
|
126
|
+
try:
|
|
127
|
+
import pika
|
|
128
|
+
|
|
129
|
+
self._ensure_open()
|
|
130
|
+
self._ensure_queue(queue_name)
|
|
131
|
+
|
|
132
|
+
message_id = uuid.uuid4().hex
|
|
133
|
+
body = json.dumps(message, default=json_safe).encode("utf-8")
|
|
134
|
+
|
|
135
|
+
self._channel.basic_publish(
|
|
136
|
+
exchange="",
|
|
137
|
+
routing_key=queue_name,
|
|
138
|
+
body=body,
|
|
139
|
+
properties=pika.BasicProperties(
|
|
140
|
+
message_id=message_id,
|
|
141
|
+
delivery_mode=2, # persistent: survives a broker restart
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return message_id
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
raise QueueError(f"RabbitMQ send failed: {e}")
|
|
149
|
+
|
|
150
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
151
|
+
def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict[str, Any]]:
|
|
152
|
+
"""
|
|
153
|
+
basic_get in a loop, not a long-lived basic_consume callback. The
|
|
154
|
+
QueueAdapter contract needs receive() to return promptly with
|
|
155
|
+
whatever's immediately available -- the same shape SQS/Azure/
|
|
156
|
+
Pub/Sub/Kafka already have. A consumer callback would instead
|
|
157
|
+
block indefinitely inside pika's IO loop waiting for messages,
|
|
158
|
+
which doesn't fit a synchronous request/response method at all.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
self._ensure_open()
|
|
162
|
+
self._ensure_queue(queue_name)
|
|
163
|
+
|
|
164
|
+
out: List[Dict[str, Any]] = []
|
|
165
|
+
|
|
166
|
+
for _ in range(max_messages):
|
|
167
|
+
method, properties, body = self._channel.basic_get(queue=queue_name, auto_ack=False)
|
|
168
|
+
if method is None:
|
|
169
|
+
break # queue is (currently) empty
|
|
170
|
+
|
|
171
|
+
message_id = (properties.message_id if properties else None) or str(
|
|
172
|
+
method.delivery_tag
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
payload = json.loads(body.decode("utf-8"))
|
|
177
|
+
except Exception:
|
|
178
|
+
payload = body.decode("utf-8", errors="replace")
|
|
179
|
+
|
|
180
|
+
self._pending[message_id] = method.delivery_tag
|
|
181
|
+
|
|
182
|
+
out.append(
|
|
183
|
+
{
|
|
184
|
+
"id": message_id,
|
|
185
|
+
"receipt_handle": message_id,
|
|
186
|
+
"body": payload,
|
|
187
|
+
"redelivered": method.redelivered,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
raise QueueError(f"RabbitMQ receive failed: {e}")
|
|
195
|
+
|
|
196
|
+
def _ack_delivery(self, message_id: str) -> bool:
|
|
197
|
+
delivery_tag = self._pending.pop(message_id, None)
|
|
198
|
+
if delivery_tag is None:
|
|
199
|
+
# Unknown/already-acked id -- treat as a no-op success, matching
|
|
200
|
+
# VercelQueueAdapter's/BlockchainQueueAdapter's existing
|
|
201
|
+
# convention for a redundant ack rather than raising.
|
|
202
|
+
return False
|
|
203
|
+
try:
|
|
204
|
+
self._ensure_open()
|
|
205
|
+
self._channel.basic_ack(delivery_tag=delivery_tag)
|
|
206
|
+
return True
|
|
207
|
+
except Exception as e:
|
|
208
|
+
raise QueueError(f"RabbitMQ ack failed: {e}")
|
|
209
|
+
|
|
210
|
+
def ack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
211
|
+
if not ack_id:
|
|
212
|
+
raise QueueError("ack_id is required for RabbitMQ ack")
|
|
213
|
+
return self._ack_delivery(ack_id)
|
|
214
|
+
|
|
215
|
+
def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
|
|
216
|
+
"""
|
|
217
|
+
Delete == ack. AMQP has no "delete without processing" primitive
|
|
218
|
+
beyond ack/nack/reject -- basic_ack is the only way to permanently
|
|
219
|
+
remove a message from the queue -- so this collapses to the same
|
|
220
|
+
call as ack(), matching the majority convention among this
|
|
221
|
+
codebase's other adapters (SQS, Pub/Sub: ack is delete).
|
|
222
|
+
"""
|
|
223
|
+
return self._ack_delivery(message_id)
|
|
224
|
+
|
|
225
|
+
def nack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
226
|
+
"""basic_nack(requeue=True) -- puts the message straight back on
|
|
227
|
+
the queue for immediate redelivery, same `_pending` delivery-tag
|
|
228
|
+
lookup ack()/delete() already use, same "unknown id is a
|
|
229
|
+
harmless no-op" convention `_ack_delivery` establishes. This is
|
|
230
|
+
deliberately NOT how dead-lettering gets triggered (that's
|
|
231
|
+
`basic_nack(requeue=False)`/`basic_reject(requeue=False)` or a
|
|
232
|
+
TTL/max-length policy on the queue itself, not a per-call
|
|
233
|
+
requeue=False here) -- this method's contract is "redeliver now",
|
|
234
|
+
not "give up on it", so it always requeues."""
|
|
235
|
+
delivery_tag = self._pending.pop(ack_id, None)
|
|
236
|
+
if delivery_tag is None:
|
|
237
|
+
return False
|
|
238
|
+
try:
|
|
239
|
+
self._ensure_open()
|
|
240
|
+
self._channel.basic_nack(delivery_tag=delivery_tag, requeue=True)
|
|
241
|
+
return True
|
|
242
|
+
except Exception as e:
|
|
243
|
+
raise QueueError(f"RabbitMQ nack failed: {e}")
|
|
244
|
+
|
|
245
|
+
def purge(self, queue_name: str = "default") -> int:
|
|
246
|
+
"""channel.queue_purge()'s Queue.PurgeOk response frame carries
|
|
247
|
+
the actual count RabbitMQ purged (`.method.message_count`) --
|
|
248
|
+
read from pika's own BlockingChannel.queue_purge return type
|
|
249
|
+
rather than assumed, verified against a real local broker."""
|
|
250
|
+
try:
|
|
251
|
+
self._ensure_open()
|
|
252
|
+
self._ensure_queue(queue_name)
|
|
253
|
+
result = self._channel.queue_purge(queue=queue_name)
|
|
254
|
+
return result.method.message_count
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise QueueError(f"RabbitMQ purge failed: {e}")
|
|
257
|
+
|
|
258
|
+
def declare(
|
|
259
|
+
self,
|
|
260
|
+
queue_name: str = "default",
|
|
261
|
+
*,
|
|
262
|
+
durable: bool = True,
|
|
263
|
+
dead_letter_queue: Optional[str] = None,
|
|
264
|
+
) -> bool:
|
|
265
|
+
"""Real AMQP dead-lettering via `x-dead-letter-exchange`/
|
|
266
|
+
`x-dead-letter-routing-key` queue arguments -- not a hand-rolled
|
|
267
|
+
shadow implementation that polls and re-routes messages itself.
|
|
268
|
+
The default exchange ("") + the DLQ's own name as routing key is
|
|
269
|
+
how AMQP delivers a dead-lettered message straight to a queue by
|
|
270
|
+
that name with no extra exchange/binding setup required.
|
|
271
|
+
|
|
272
|
+
The DLQ target is declared first, as an ordinary durable queue,
|
|
273
|
+
so the dead-letter-routing-key always has somewhere real to land
|
|
274
|
+
before the source queue (which references it) is declared.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
self._ensure_open()
|
|
278
|
+
|
|
279
|
+
arguments: Dict[str, Any] = {}
|
|
280
|
+
if dead_letter_queue:
|
|
281
|
+
self._channel.queue_declare(queue=dead_letter_queue, durable=True)
|
|
282
|
+
self._declared_queues.add(dead_letter_queue)
|
|
283
|
+
arguments["x-dead-letter-exchange"] = ""
|
|
284
|
+
arguments["x-dead-letter-routing-key"] = dead_letter_queue
|
|
285
|
+
|
|
286
|
+
self._channel.queue_declare(
|
|
287
|
+
queue=queue_name, durable=durable, arguments=arguments or None
|
|
288
|
+
)
|
|
289
|
+
# Marking it declared here is what keeps _ensure_queue()'s own
|
|
290
|
+
# implicit auto-create path (plain durable=True, no
|
|
291
|
+
# arguments) from ever re-declaring this queue with different
|
|
292
|
+
# arguments on the first send()/receive() -- see that
|
|
293
|
+
# method's docstring for the PRECONDITION_FAILED this avoids.
|
|
294
|
+
self._declared_queues.add(queue_name)
|
|
295
|
+
return True
|
|
296
|
+
except Exception as e:
|
|
297
|
+
raise QueueError(f"RabbitMQ declare failed: {e}")
|
|
298
|
+
|
|
299
|
+
def status(self, queue_name: str = "default") -> Dict[str, Any]:
|
|
300
|
+
"""queue_declare(passive=True) -- "don't create it, just inspect
|
|
301
|
+
it" (raises if the queue doesn't already exist, unlike a plain
|
|
302
|
+
queue_declare) -- confirmed against pika's actual
|
|
303
|
+
BlockingChannel.queue_declare behavior, not assumed. Its
|
|
304
|
+
Queue.DeclareOk response frame carries both message_count and
|
|
305
|
+
consumer_count for a classic queue."""
|
|
306
|
+
try:
|
|
307
|
+
self._ensure_open()
|
|
308
|
+
result = self._channel.queue_declare(queue=queue_name, passive=True)
|
|
309
|
+
return {
|
|
310
|
+
"message_count": result.method.message_count,
|
|
311
|
+
"consumer_count": result.method.consumer_count,
|
|
312
|
+
}
|
|
313
|
+
except Exception as e:
|
|
314
|
+
raise QueueError(f"RabbitMQ status failed: {e}")
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------
|
|
317
|
+
# delay / cancel -- real, plugin-free AMQP TTL + dead-letter-exchange
|
|
318
|
+
# pattern. extend() is NOT overridden: AMQP's ack/nack/reject model
|
|
319
|
+
# has no per-message renewable visibility timer at all, so the base
|
|
320
|
+
# class's NotImplementedError is the honest answer.
|
|
321
|
+
# ---------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
# Bound on how many messages a single cancel() scan will basic_get
|
|
324
|
+
# through looking for the target message_id, so an adversarial or
|
|
325
|
+
# just very full delay queue can't make one cancel() call block
|
|
326
|
+
# (or nack-and-requeue-into-a-loop) indefinitely.
|
|
327
|
+
MAX_CANCEL_SCAN = 10_000
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def _delay_queue_name(queue_name: str, delay_seconds: int) -> str:
|
|
331
|
+
return f"{queue_name}.delay.{delay_seconds}"
|
|
332
|
+
|
|
333
|
+
def _ensure_delay_queue(self, queue_name: str, delay_seconds: int) -> str:
|
|
334
|
+
"""Declare (once) the per-duration delay queue that dead-letters
|
|
335
|
+
into the real queue_name once its own TTL elapses -- the same
|
|
336
|
+
dead-lettering mechanism declare()'s own dead_letter_queue
|
|
337
|
+
argument already uses, applied here to delay instead of
|
|
338
|
+
failure."""
|
|
339
|
+
delay_queue = self._delay_queue_name(queue_name, delay_seconds)
|
|
340
|
+
if delay_queue in self._declared_queues:
|
|
341
|
+
return delay_queue
|
|
342
|
+
|
|
343
|
+
self._channel.queue_declare(
|
|
344
|
+
queue=delay_queue,
|
|
345
|
+
durable=True,
|
|
346
|
+
arguments={
|
|
347
|
+
"x-message-ttl": delay_seconds * 1000,
|
|
348
|
+
"x-dead-letter-exchange": "",
|
|
349
|
+
"x-dead-letter-routing-key": queue_name,
|
|
350
|
+
},
|
|
351
|
+
)
|
|
352
|
+
self._declared_queues.add(delay_queue)
|
|
353
|
+
return delay_queue
|
|
354
|
+
|
|
355
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
356
|
+
def delay(
|
|
357
|
+
self, message: Dict[str, Any], queue_name: str = "default", *, delay_seconds: int = 0
|
|
358
|
+
) -> str:
|
|
359
|
+
"""Publish to a per-duration delay queue (x-message-ttl +
|
|
360
|
+
dead-letter-exchange pointing at queue_name) so the message
|
|
361
|
+
lands there automatically once delay_seconds elapses. Returns a
|
|
362
|
+
self-generated message_id cancel() can use to remove it first,
|
|
363
|
+
the same embed-our-own-id approach send() already uses since
|
|
364
|
+
basic_publish itself returns nothing."""
|
|
365
|
+
try:
|
|
366
|
+
import pika
|
|
367
|
+
|
|
368
|
+
self._ensure_open()
|
|
369
|
+
self._ensure_queue(queue_name)
|
|
370
|
+
delay_queue = self._ensure_delay_queue(queue_name, delay_seconds)
|
|
371
|
+
|
|
372
|
+
message_id = uuid.uuid4().hex
|
|
373
|
+
body = json.dumps(message, default=json_safe).encode("utf-8")
|
|
374
|
+
|
|
375
|
+
self._channel.basic_publish(
|
|
376
|
+
exchange="",
|
|
377
|
+
routing_key=delay_queue,
|
|
378
|
+
body=body,
|
|
379
|
+
properties=pika.BasicProperties(
|
|
380
|
+
message_id=message_id,
|
|
381
|
+
delivery_mode=2,
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return message_id
|
|
386
|
+
|
|
387
|
+
except Exception as e:
|
|
388
|
+
raise QueueError(f"RabbitMQ delay failed: {e}")
|
|
389
|
+
|
|
390
|
+
def cancel(
|
|
391
|
+
self, message_id: str, queue_name: str = "default", *, delay_seconds: Optional[int] = None
|
|
392
|
+
) -> bool:
|
|
393
|
+
"""Remove a still-delayed message before its delay queue's own
|
|
394
|
+
TTL elapses, via a bounded scan-and-requeue of that one specific
|
|
395
|
+
per-duration delay queue: basic_get everything currently in it
|
|
396
|
+
(up to MAX_CANCEL_SCAN), basic_ack the one matching message_id
|
|
397
|
+
to drop it, basic_nack(requeue=True) every other message to keep
|
|
398
|
+
them correctly scheduled.
|
|
399
|
+
|
|
400
|
+
`delay_seconds` identifies WHICH delay queue to scan -- the same
|
|
401
|
+
value passed to the original delay() call. Without it there is
|
|
402
|
+
no way to know which per-duration queue the message is sitting
|
|
403
|
+
in, so it is required here (unlike the base contract's bare
|
|
404
|
+
`message_id`) and this override intentionally has a different,
|
|
405
|
+
wider signature than QueueAdapter.cancel's own.
|
|
406
|
+
"""
|
|
407
|
+
if delay_seconds is None:
|
|
408
|
+
raise QueueError(
|
|
409
|
+
"RabbitMQAdapter.cancel requires delay_seconds (the same value passed to delay()) "
|
|
410
|
+
"to know which per-duration delay queue to scan"
|
|
411
|
+
)
|
|
412
|
+
if not message_id:
|
|
413
|
+
raise QueueError("message_id is required for RabbitMQ cancel")
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
self._ensure_open()
|
|
417
|
+
delay_queue = self._ensure_delay_queue(queue_name, delay_seconds)
|
|
418
|
+
|
|
419
|
+
found = False
|
|
420
|
+
scanned = 0
|
|
421
|
+
# Two passes: first drain up to MAX_CANCEL_SCAN messages,
|
|
422
|
+
# remembering which delivery_tag (if any) matches
|
|
423
|
+
# message_id; then ack the match and nack(requeue=True)
|
|
424
|
+
# everything else, so a message that doesn't match is
|
|
425
|
+
# restored to the queue rather than lost.
|
|
426
|
+
to_requeue = []
|
|
427
|
+
match_tag = None
|
|
428
|
+
|
|
429
|
+
while scanned < self.MAX_CANCEL_SCAN:
|
|
430
|
+
method, properties, body = self._channel.basic_get(
|
|
431
|
+
queue=delay_queue, auto_ack=False
|
|
432
|
+
)
|
|
433
|
+
if method is None:
|
|
434
|
+
break
|
|
435
|
+
scanned += 1
|
|
436
|
+
this_id = properties.message_id if properties else None
|
|
437
|
+
if this_id == message_id and match_tag is None:
|
|
438
|
+
match_tag = method.delivery_tag
|
|
439
|
+
found = True
|
|
440
|
+
else:
|
|
441
|
+
to_requeue.append(method.delivery_tag)
|
|
442
|
+
|
|
443
|
+
if match_tag is not None:
|
|
444
|
+
self._channel.basic_ack(delivery_tag=match_tag)
|
|
445
|
+
for tag in to_requeue:
|
|
446
|
+
self._channel.basic_nack(delivery_tag=tag, requeue=True)
|
|
447
|
+
|
|
448
|
+
return found
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
raise QueueError(f"RabbitMQ cancel failed: {e}")
|
|
452
|
+
|
|
453
|
+
def close(self) -> None:
|
|
454
|
+
"""Close the channel/connection. Not part of QueueAdapter's
|
|
455
|
+
abstract contract, but good hygiene for a long-lived instance."""
|
|
456
|
+
try:
|
|
457
|
+
if self._channel is not None and self._channel.is_open:
|
|
458
|
+
self._channel.close()
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
try:
|
|
462
|
+
if self._connection is not None and self._connection.is_open:
|
|
463
|
+
self._connection.close()
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
polydb/adapters/SQSAdapter.py
CHANGED
|
@@ -180,3 +180,63 @@ class SQSAdapter(QueueAdapter):
|
|
|
180
180
|
raise QueueError("ack_id (receipt_handle) is required for SQS ack")
|
|
181
181
|
|
|
182
182
|
return self.delete(ack_id, queue_name=queue_name)
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------
|
|
185
|
+
# extend / delay -- real SQS primitives. cancel() is NOT
|
|
186
|
+
# overridden: a delayed SQS message has no ReceiptHandle until its
|
|
187
|
+
# own DelaySeconds elapses, so there is no API call that can remove
|
|
188
|
+
# it before then -- the base class's NotImplementedError is the
|
|
189
|
+
# honest answer, not a gap to work around.
|
|
190
|
+
# ---------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
193
|
+
def extend(
|
|
194
|
+
self, ack_id: str, queue_name: str = "default", *, visibility_timeout: int = 30
|
|
195
|
+
) -> bool:
|
|
196
|
+
"""Real SQS ChangeMessageVisibility -- ack_id is the same
|
|
197
|
+
receipt_handle receive() returned for this message."""
|
|
198
|
+
if not ack_id:
|
|
199
|
+
raise QueueError("ack_id (receipt_handle) is required for SQS extend")
|
|
200
|
+
try:
|
|
201
|
+
if not self._client:
|
|
202
|
+
self._initialize_client()
|
|
203
|
+
|
|
204
|
+
self._client.change_message_visibility(
|
|
205
|
+
QueueUrl=self._queue_url,
|
|
206
|
+
ReceiptHandle=ack_id,
|
|
207
|
+
VisibilityTimeout=visibility_timeout,
|
|
208
|
+
)
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
except Exception as e:
|
|
212
|
+
raise QueueError(f"SQS extend failed: {e}")
|
|
213
|
+
|
|
214
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
215
|
+
def delay(
|
|
216
|
+
self, message: Dict[str, Any], queue_name: str = "default", *, delay_seconds: int = 0
|
|
217
|
+
) -> str:
|
|
218
|
+
"""Real SQS SendMessage DelaySeconds -- SQS itself caps this at
|
|
219
|
+
900 seconds (15 minutes), enforced here rather than left to an
|
|
220
|
+
opaque API error."""
|
|
221
|
+
if not (0 <= delay_seconds <= 900):
|
|
222
|
+
raise QueueError(
|
|
223
|
+
f"SQS delay_seconds must be between 0 and 900 (SQS's own real ceiling), got {delay_seconds}"
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
if not self._client:
|
|
227
|
+
self._initialize_client()
|
|
228
|
+
|
|
229
|
+
body = (
|
|
230
|
+
json.dumps(message, default=json_safe) if not isinstance(message, str) else message
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
resp = self._client.send_message(
|
|
234
|
+
QueueUrl=self._queue_url,
|
|
235
|
+
MessageBody=body,
|
|
236
|
+
DelaySeconds=delay_seconds,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return resp["MessageId"]
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
raise QueueError(f"SQS delay failed: {e}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# src/polydb/adapters/VaultAdapter.py
|
|
2
|
+
#
|
|
3
|
+
# HashiCorp Vault (OSS, self-hosted) -- the cloud-agnostic default secrets
|
|
4
|
+
# backend, alongside the native per-cloud adapters (Azure Key Vault, AWS
|
|
5
|
+
# Secrets Manager, GCP Secret Manager).
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..base.SecretsAdapter import SecretsAdapter
|
|
10
|
+
from ..errors import ConnectionError
|
|
11
|
+
from ..retry import retry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VaultAdapter(SecretsAdapter):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
url: str = "",
|
|
18
|
+
token: str = "",
|
|
19
|
+
mount_point: str = "secret",
|
|
20
|
+
):
|
|
21
|
+
super().__init__()
|
|
22
|
+
|
|
23
|
+
self.url = url or os.getenv("VAULT_ADDR")
|
|
24
|
+
self.token = token or os.getenv("VAULT_TOKEN")
|
|
25
|
+
self.mount_point = mount_point
|
|
26
|
+
|
|
27
|
+
if not self.url or not self.token:
|
|
28
|
+
raise ConnectionError("VAULT_ADDR / VAULT_TOKEN are not configured")
|
|
29
|
+
|
|
30
|
+
self._client = None
|
|
31
|
+
self._initialize_client()
|
|
32
|
+
|
|
33
|
+
def _initialize_client(self) -> None:
|
|
34
|
+
import hvac
|
|
35
|
+
|
|
36
|
+
self._client = hvac.Client(url=self.url, token=self.token)
|
|
37
|
+
if not self._client.is_authenticated():
|
|
38
|
+
raise ConnectionError("Vault authentication failed")
|
|
39
|
+
|
|
40
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
|
|
41
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
42
|
+
try:
|
|
43
|
+
resp = self._client.secrets.kv.v2.read_secret_version(
|
|
44
|
+
path=key, mount_point=self.mount_point, raise_on_deleted_version=True
|
|
45
|
+
)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
if "InvalidPath" in type(exc).__name__ or "not found" in str(exc).lower():
|
|
48
|
+
return None
|
|
49
|
+
raise
|
|
50
|
+
return resp["data"]["data"].get("value")
|
|
51
|
+
|
|
52
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
|
|
53
|
+
def set_secret(self, key: str, value: str) -> None:
|
|
54
|
+
self._client.secrets.kv.v2.create_or_update_secret(
|
|
55
|
+
path=key, secret={"value": value}, mount_point=self.mount_point
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
|
|
59
|
+
def delete_secret(self, key: str) -> bool:
|
|
60
|
+
self._client.secrets.kv.v2.delete_metadata_and_all_versions(
|
|
61
|
+
path=key, mount_point=self.mount_point
|
|
62
|
+
)
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(ConnectionError, Exception))
|
|
66
|
+
def list_secrets(self, prefix: str = "") -> list[str]:
|
|
67
|
+
try:
|
|
68
|
+
resp = self._client.secrets.kv.v2.list_secrets(
|
|
69
|
+
path=prefix, mount_point=self.mount_point
|
|
70
|
+
)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
if "InvalidPath" in type(exc).__name__ or "not found" in str(exc).lower():
|
|
73
|
+
return []
|
|
74
|
+
raise
|
|
75
|
+
return resp["data"]["keys"]
|