altcodepro-polydb-python 2.5.3__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.3.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/METADATA +69 -20
- {altcodepro_polydb_python-2.5.3.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/RECORD +29 -22
- 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/AzureTableStorageAdapter.py +32 -5
- 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.3.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.5.3.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.3.dist-info → altcodepro_polydb_python-2.5.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# src/polydb/adapters/KafkaQueueAdapter.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 KafkaQueueAdapter(QueueAdapter):
|
|
15
|
+
"""
|
|
16
|
+
Apache Kafka adapter using the synchronous `kafka-python` client, not
|
|
17
|
+
`aiokafka`. Every other adapter in this codebase (SQS, Azure Queue,
|
|
18
|
+
Pub/Sub, RabbitMQ, ...) is synchronous end to end -- pulling in an
|
|
19
|
+
asyncio-native client here would mean either spinning an event loop
|
|
20
|
+
per call or wrapping every call in asyncio.run(), both strictly worse
|
|
21
|
+
than using the client kafka-python already provides for exactly this
|
|
22
|
+
call shape.
|
|
23
|
+
|
|
24
|
+
queue_name IS the Kafka topic.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
bootstrap_servers: str = "",
|
|
30
|
+
group_id: str = "",
|
|
31
|
+
client_id: str = "",
|
|
32
|
+
security_protocol: str = "",
|
|
33
|
+
sasl_mechanism: str = "",
|
|
34
|
+
sasl_plain_username: str = "",
|
|
35
|
+
sasl_plain_password: str = "",
|
|
36
|
+
ssl_cafile: str = "",
|
|
37
|
+
auto_offset_reset: str = "earliest",
|
|
38
|
+
):
|
|
39
|
+
super().__init__()
|
|
40
|
+
|
|
41
|
+
servers = bootstrap_servers or os.getenv("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")
|
|
42
|
+
self.bootstrap_servers = [s.strip() for s in servers.split(",") if s.strip()]
|
|
43
|
+
|
|
44
|
+
# A random per-instance consumer group by default so that two
|
|
45
|
+
# independently-constructed adapters (e.g. two test runs, or two
|
|
46
|
+
# unrelated callers in the same process) don't silently share
|
|
47
|
+
# partition assignment / offsets with each other. Callers that
|
|
48
|
+
# actually want shared work-queue semantics across processes pass
|
|
49
|
+
# an explicit group_id (or set KAFKA_GROUP_ID).
|
|
50
|
+
self.group_id = (
|
|
51
|
+
group_id or os.getenv("KAFKA_GROUP_ID") or f"polydb-{uuid.uuid4().hex[:12]}"
|
|
52
|
+
)
|
|
53
|
+
self.client_id = client_id or os.getenv("KAFKA_CLIENT_ID", "polydb")
|
|
54
|
+
self.auto_offset_reset = auto_offset_reset
|
|
55
|
+
|
|
56
|
+
# Auth/TLS are all optional -- PLAINTEXT (no auth) is the default
|
|
57
|
+
# so local/dev brokers work with zero extra config, matching how
|
|
58
|
+
# e.g. SQSAdapter defaults endpoint_url to "" (real AWS) rather
|
|
59
|
+
# than requiring LocalStack settings to be supplied.
|
|
60
|
+
self.security_protocol = security_protocol or os.getenv(
|
|
61
|
+
"KAFKA_SECURITY_PROTOCOL", "PLAINTEXT"
|
|
62
|
+
)
|
|
63
|
+
self.sasl_mechanism = sasl_mechanism or os.getenv("KAFKA_SASL_MECHANISM") or None
|
|
64
|
+
self.sasl_plain_username = (
|
|
65
|
+
sasl_plain_username or os.getenv("KAFKA_SASL_USERNAME") or None
|
|
66
|
+
)
|
|
67
|
+
self.sasl_plain_password = (
|
|
68
|
+
sasl_plain_password or os.getenv("KAFKA_SASL_PASSWORD") or None
|
|
69
|
+
)
|
|
70
|
+
self.ssl_cafile = ssl_cafile or os.getenv("KAFKA_SSL_CAFILE") or None
|
|
71
|
+
|
|
72
|
+
self._producer: Any = None
|
|
73
|
+
self._consumers: Dict[str, Any] = {} # topic -> KafkaConsumer
|
|
74
|
+
|
|
75
|
+
# message_id -> (TopicPartition, offset_to_commit). Populated by
|
|
76
|
+
# receive(), consumed (and popped) by ack()/delete(). See the long
|
|
77
|
+
# comment on receive() for why committing only happens here.
|
|
78
|
+
self._pending: Dict[str, Any] = {}
|
|
79
|
+
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------
|
|
83
|
+
# Client initialization
|
|
84
|
+
# ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _client_kwargs(self) -> Dict[str, Any]:
|
|
87
|
+
kwargs: Dict[str, Any] = {
|
|
88
|
+
"bootstrap_servers": self.bootstrap_servers,
|
|
89
|
+
"security_protocol": self.security_protocol,
|
|
90
|
+
}
|
|
91
|
+
if self.sasl_mechanism:
|
|
92
|
+
kwargs["sasl_mechanism"] = self.sasl_mechanism
|
|
93
|
+
kwargs["sasl_plain_username"] = self.sasl_plain_username
|
|
94
|
+
kwargs["sasl_plain_password"] = self.sasl_plain_password
|
|
95
|
+
if self.ssl_cafile:
|
|
96
|
+
kwargs["ssl_cafile"] = self.ssl_cafile
|
|
97
|
+
return kwargs
|
|
98
|
+
|
|
99
|
+
def _get_producer(self):
|
|
100
|
+
from kafka import KafkaProducer
|
|
101
|
+
|
|
102
|
+
if self._producer is not None:
|
|
103
|
+
return self._producer
|
|
104
|
+
|
|
105
|
+
with self._lock:
|
|
106
|
+
if self._producer is not None:
|
|
107
|
+
return self._producer
|
|
108
|
+
try:
|
|
109
|
+
self._producer = KafkaProducer(
|
|
110
|
+
client_id=self.client_id,
|
|
111
|
+
# We JSON-encode to bytes ourselves (matches json_safe
|
|
112
|
+
# usage elsewhere in this codebase), so the serializer
|
|
113
|
+
# is a passthrough rather than kafka-python's own.
|
|
114
|
+
value_serializer=lambda v: v,
|
|
115
|
+
**self._client_kwargs(),
|
|
116
|
+
)
|
|
117
|
+
self.logger.info(
|
|
118
|
+
f"Initialized Kafka producer (bootstrap={self.bootstrap_servers})"
|
|
119
|
+
)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
raise ConnectionError(f"Kafka producer init failed: {e}")
|
|
122
|
+
return self._producer
|
|
123
|
+
|
|
124
|
+
def _get_consumer(self, topic: str):
|
|
125
|
+
if topic in self._consumers:
|
|
126
|
+
return self._consumers[topic]
|
|
127
|
+
|
|
128
|
+
from kafka import KafkaConsumer
|
|
129
|
+
|
|
130
|
+
with self._lock:
|
|
131
|
+
if topic in self._consumers:
|
|
132
|
+
return self._consumers[topic]
|
|
133
|
+
try:
|
|
134
|
+
consumer = KafkaConsumer(
|
|
135
|
+
topic,
|
|
136
|
+
group_id=self.group_id,
|
|
137
|
+
client_id=self.client_id,
|
|
138
|
+
# Manual commits only -- see receive()'s docstring for
|
|
139
|
+
# why offsets are committed exclusively from ack()/
|
|
140
|
+
# delete(), never automatically here.
|
|
141
|
+
enable_auto_commit=False,
|
|
142
|
+
auto_offset_reset=self.auto_offset_reset,
|
|
143
|
+
**self._client_kwargs(),
|
|
144
|
+
)
|
|
145
|
+
self.logger.info(f"Initialized Kafka consumer (topic={topic}, group={self.group_id})")
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise ConnectionError(f"Kafka consumer init failed: {e}")
|
|
148
|
+
self._consumers[topic] = consumer
|
|
149
|
+
return consumer
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------
|
|
152
|
+
# Queue operations
|
|
153
|
+
# ---------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
156
|
+
def send(self, message: Dict[str, Any], queue_name: str = "default") -> str:
|
|
157
|
+
"""Produce a message to `queue_name` (the Kafka topic)."""
|
|
158
|
+
try:
|
|
159
|
+
producer = self._get_producer()
|
|
160
|
+
body = (
|
|
161
|
+
json.dumps(message, default=json_safe).encode("utf-8")
|
|
162
|
+
if not isinstance(message, (bytes, bytearray))
|
|
163
|
+
else message
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
future = producer.send(queue_name, value=body)
|
|
167
|
+
# kafka-python's send() is async by default (it returns a
|
|
168
|
+
# FutureRecordMetadata immediately, before the broker has
|
|
169
|
+
# necessarily accepted the record). .get() blocks for that ack
|
|
170
|
+
# so send() returns only once the message is durably produced
|
|
171
|
+
# -- matching every other adapter's synchronous
|
|
172
|
+
# send()->message_id contract instead of firing-and-forgetting.
|
|
173
|
+
record = future.get(timeout=10)
|
|
174
|
+
return f"{record.partition}-{record.offset}"
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
raise QueueError(f"Kafka send failed: {e}")
|
|
178
|
+
|
|
179
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
180
|
+
def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict[str, Any]]:
|
|
181
|
+
"""
|
|
182
|
+
Poll up to `max_messages` from `queue_name`'s consumer group.
|
|
183
|
+
|
|
184
|
+
Deliberately does NOT commit offsets here (the consumer is created
|
|
185
|
+
with enable_auto_commit=False too). A message only becomes "done"
|
|
186
|
+
from Kafka's point of view once ack()/delete() commits its offset.
|
|
187
|
+
If the caller crashes after receive() but before ack(), nothing
|
|
188
|
+
was ever committed, so the next poll -- this process restarted, or
|
|
189
|
+
any other consumer sharing this group_id -- redelivers the message
|
|
190
|
+
from the same offset. That's the same at-least-once shape SQS's
|
|
191
|
+
visibility timeout, Azure's visibility timeout, and Pub/Sub's
|
|
192
|
+
unacked-redelivery already give this codebase's other queue
|
|
193
|
+
adapters. Auto-committing inside receive() would instead mark a
|
|
194
|
+
message "consumed" the instant it's handed out, which loses
|
|
195
|
+
redelivery on a crash mid-processing -- effectively at-most-once,
|
|
196
|
+
not the at-least-once contract the rest of this codebase relies on
|
|
197
|
+
(WorkerPool retries on a failed/never-acked message).
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
from kafka import TopicPartition
|
|
201
|
+
|
|
202
|
+
consumer = self._get_consumer(queue_name)
|
|
203
|
+
|
|
204
|
+
out: List[Dict[str, Any]] = []
|
|
205
|
+
# poll() returns whatever's ready in a single batch, which may
|
|
206
|
+
# be less than max_messages even when more exists -- loop
|
|
207
|
+
# (bounded) rather than assuming one poll() satisfies the ask.
|
|
208
|
+
attempts = 0
|
|
209
|
+
while len(out) < max_messages and attempts < 5:
|
|
210
|
+
attempts += 1
|
|
211
|
+
remaining = max_messages - len(out)
|
|
212
|
+
batches = consumer.poll(timeout_ms=1000, max_records=remaining)
|
|
213
|
+
if not batches:
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
for tp, records in batches.items():
|
|
217
|
+
for record in records:
|
|
218
|
+
message_id = f"{record.partition}-{record.offset}"
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
body = json.loads(record.value.decode("utf-8"))
|
|
222
|
+
except Exception:
|
|
223
|
+
body = record.value.decode("utf-8", errors="replace")
|
|
224
|
+
|
|
225
|
+
out.append(
|
|
226
|
+
{
|
|
227
|
+
"id": message_id,
|
|
228
|
+
"receipt_handle": message_id,
|
|
229
|
+
"body": body,
|
|
230
|
+
"topic": tp.topic,
|
|
231
|
+
"partition": tp.partition,
|
|
232
|
+
"offset": record.offset,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Kafka commit semantics: the committed offset is
|
|
237
|
+
# "the next record to read", so we store
|
|
238
|
+
# offset + 1, not offset itself.
|
|
239
|
+
self._pending[message_id] = (
|
|
240
|
+
TopicPartition(tp.topic, tp.partition),
|
|
241
|
+
record.offset + 1,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if len(out) >= max_messages:
|
|
245
|
+
break
|
|
246
|
+
if len(out) >= max_messages:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
raise QueueError(f"Kafka receive failed: {e}")
|
|
253
|
+
|
|
254
|
+
def _commit(self, message_id: str, queue_name: str) -> bool:
|
|
255
|
+
from kafka import OffsetAndMetadata
|
|
256
|
+
|
|
257
|
+
pending = self._pending.pop(message_id, None)
|
|
258
|
+
if pending is None:
|
|
259
|
+
# Unknown or already-committed id -- treat as a no-op success.
|
|
260
|
+
# Matches VercelQueueAdapter/BlockchainQueueAdapter's existing
|
|
261
|
+
# convention of a redundant ack being harmless rather than an
|
|
262
|
+
# error.
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
tp, next_offset = pending
|
|
266
|
+
consumer = self._consumers.get(queue_name)
|
|
267
|
+
if consumer is None:
|
|
268
|
+
raise QueueError(
|
|
269
|
+
f"No active Kafka consumer for topic '{queue_name}' to commit offset against"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
consumer.commit({tp: OffsetAndMetadata(next_offset, None)})
|
|
274
|
+
return True
|
|
275
|
+
except Exception as e:
|
|
276
|
+
raise QueueError(f"Kafka offset commit failed: {e}")
|
|
277
|
+
|
|
278
|
+
def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
|
|
279
|
+
"""
|
|
280
|
+
Delete == commit the offset. Kafka has no notion of deleting a
|
|
281
|
+
single record independent of consumer offsets, so -- matching the
|
|
282
|
+
majority convention among this codebase's other adapters (SQS,
|
|
283
|
+
Pub/Sub: ack is delete) -- delete() and ack() do the same thing.
|
|
284
|
+
"""
|
|
285
|
+
return self._commit(message_id, queue_name)
|
|
286
|
+
|
|
287
|
+
def ack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
288
|
+
"""Explicit ACK: commits the consumed offset for `ack_id`."""
|
|
289
|
+
if not ack_id:
|
|
290
|
+
raise QueueError("ack_id is required for Kafka ack")
|
|
291
|
+
return self._commit(ack_id, queue_name)
|
|
292
|
+
|
|
293
|
+
def nack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
294
|
+
"""
|
|
295
|
+
Documented no-op, not a design gap: receive()'s own docstring
|
|
296
|
+
already establishes that this adapter deliberately never commits
|
|
297
|
+
an offset until ack()/delete() does. That means a message is
|
|
298
|
+
already effectively "nacked" -- redeliverable to this (or any
|
|
299
|
+
other) consumer sharing group_id -- the instant it's received
|
|
300
|
+
and not yet acked; there is no separate broker-side "put it back"
|
|
301
|
+
call the way AMQP's basic_nack is, because nothing was ever
|
|
302
|
+
marked done in the first place.
|
|
303
|
+
|
|
304
|
+
This only pops the pending entry (mirroring _commit's own
|
|
305
|
+
"unknown/already-handled id is a harmless no-op" convention, and
|
|
306
|
+
returning the same True-if-there-was-something-to-act-on /
|
|
307
|
+
False-if-not shape _commit does) so a caller that explicitly
|
|
308
|
+
nacks doesn't also get to ack() the same id afterward -- it
|
|
309
|
+
exists for API-shape consistency with the other adapters'
|
|
310
|
+
nack(), not because Kafka needs an explicit call here to achieve
|
|
311
|
+
the redelivery.
|
|
312
|
+
"""
|
|
313
|
+
return self._pending.pop(ack_id, None) is not None
|
|
314
|
+
|
|
315
|
+
def close(self) -> None:
|
|
316
|
+
"""
|
|
317
|
+
Flush the producer and close every consumer. Not part of
|
|
318
|
+
QueueAdapter's abstract contract (none of the other adapters need
|
|
319
|
+
it -- boto3/Azure SDK/Pub/Sub clients don't hold a persistent
|
|
320
|
+
local socket the way a Kafka producer/consumer does), but good
|
|
321
|
+
hygiene for a long-lived adapter instance to call explicitly.
|
|
322
|
+
"""
|
|
323
|
+
if self._producer is not None:
|
|
324
|
+
try:
|
|
325
|
+
self._producer.close()
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
for consumer in self._consumers.values():
|
|
329
|
+
try:
|
|
330
|
+
consumer.close()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import os
|
|
3
3
|
import threading
|
|
4
4
|
import time
|
|
5
|
-
from typing import Any, Iterator, List, Optional, Tuple, Union
|
|
5
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
|
6
6
|
import hashlib
|
|
7
7
|
from contextlib import contextmanager
|
|
8
8
|
import json
|
|
@@ -20,6 +20,20 @@ from ..utils import validate_table_name, validate_column_name
|
|
|
20
20
|
from ..query import QueryBuilder, Operator
|
|
21
21
|
from ..types import JsonDict, Lookup
|
|
22
22
|
|
|
23
|
+
# Postgres session variables this adapter is willing to SET (scoped to one
|
|
24
|
+
# transaction, via set_config(..., is_local=True)) on a caller's behalf.
|
|
25
|
+
# `session_vars` is a keyword-only parameter no request body/query string
|
|
26
|
+
# ever reaches directly -- every caller is trusted engine code, never
|
|
27
|
+
# attacker input -- but this allow-list is a second, independent guard
|
|
28
|
+
# against a future caller (or a bug in one) asking this adapter to SET an
|
|
29
|
+
# arbitrary Postgres GUC (search_path, role, statement_timeout, ...) it was
|
|
30
|
+
# never built to accept. polydb stays tenant-unaware on purpose (see
|
|
31
|
+
# databaseFactory.py's own module docstring) -- this mechanism is generic
|
|
32
|
+
# ("set these session vars for one transaction"), not tenant-specific; the
|
|
33
|
+
# one entry below is simply the one real caller (universal_engine's RLS
|
|
34
|
+
# policies) needs today. Add a name here only when a real caller needs it.
|
|
35
|
+
ALLOWED_SESSION_VARS = frozenset({"app.tenant_id"})
|
|
36
|
+
|
|
23
37
|
|
|
24
38
|
class PostgreSQLAdapter:
|
|
25
39
|
"""PostgreSQL with full LINQ support, connection pooling, JSON/JSONB support"""
|
|
@@ -208,6 +222,18 @@ class PostgreSQLAdapter:
|
|
|
208
222
|
# ---------------------------------------------------------------------
|
|
209
223
|
|
|
210
224
|
def reset_pool(self):
|
|
225
|
+
# `self._lock` is a plain threading.Lock (not reentrant) --
|
|
226
|
+
# _initialize_pool() below acquires it itself, so it must NOT
|
|
227
|
+
# still be held when we call it, or this self-deadlocks (found by
|
|
228
|
+
# actually calling reset_pool() for the first time anywhere in
|
|
229
|
+
# this codebase, in tests/test_session_vars.py's pooled-connection
|
|
230
|
+
# leak test -- no prior caller ever exercised this method). The
|
|
231
|
+
# tiny window here where self._pool is briefly None before
|
|
232
|
+
# _initialize_pool() re-acquires the lock and sets it back is
|
|
233
|
+
# safe: that method's own `if not self._pool` check under its own
|
|
234
|
+
# lock is exactly what already protects concurrent callers of it
|
|
235
|
+
# (e.g. _get_connection()'s own lazy re-init), so it's not a new
|
|
236
|
+
# exposure.
|
|
211
237
|
with self._lock:
|
|
212
238
|
if self._pool:
|
|
213
239
|
try:
|
|
@@ -215,7 +241,7 @@ class PostgreSQLAdapter:
|
|
|
215
241
|
except Exception:
|
|
216
242
|
pass
|
|
217
243
|
self._pool = None
|
|
218
|
-
|
|
244
|
+
self._initialize_pool()
|
|
219
245
|
|
|
220
246
|
def begin_transaction(self) -> Any:
|
|
221
247
|
conn = self._get_connection()
|
|
@@ -223,6 +249,40 @@ class PostgreSQLAdapter:
|
|
|
223
249
|
conn.autocommit = False
|
|
224
250
|
return conn
|
|
225
251
|
|
|
252
|
+
def _apply_session_vars(self, conn: Any, session_vars: Optional[Dict[str, str]]) -> None:
|
|
253
|
+
"""SET LOCAL-equivalent for a fixed, allow-listed set of Postgres
|
|
254
|
+
session variables, scoped to whatever transaction `conn` is
|
|
255
|
+
currently in -- reverts automatically at COMMIT/ROLLBACK, never
|
|
256
|
+
outlives the transaction it was set for. This is the fix for the
|
|
257
|
+
gap README documents: `SET LOCAL <var> = <value>` only means
|
|
258
|
+
anything on the exact connection/transaction the real query after
|
|
259
|
+
it runs on, so it has to be issued here, immediately before the
|
|
260
|
+
query, on the same `conn` -- never as a separate call the caller
|
|
261
|
+
could race against a pool checkin/checkout.
|
|
262
|
+
|
|
263
|
+
Uses `set_config(name, value, is_local)` rather than a literal
|
|
264
|
+
`SET LOCAL {key} = %s` string: `SET` itself has no bind-parameter
|
|
265
|
+
syntax in Postgres (the value has no placeholder form), but
|
|
266
|
+
`set_config` is an ordinary SQL function, so both the name and the
|
|
267
|
+
value are real query parameters -- nothing about either is ever
|
|
268
|
+
f-string'd into the SQL text. `is_local=True` is what gives this
|
|
269
|
+
`SET LOCAL`'s exact semantics (reverts at the end of the current
|
|
270
|
+
transaction) rather than `SET`'s session-lifetime one -- the
|
|
271
|
+
session-lifetime form is exactly the pooled-connection leak this
|
|
272
|
+
whole mechanism exists to avoid (see tests/test_session_vars.py's
|
|
273
|
+
leak-across-checkout proof).
|
|
274
|
+
"""
|
|
275
|
+
if not session_vars:
|
|
276
|
+
return
|
|
277
|
+
with conn.cursor() as cur:
|
|
278
|
+
for key, value in session_vars.items():
|
|
279
|
+
if key not in ALLOWED_SESSION_VARS:
|
|
280
|
+
raise DatabaseError(
|
|
281
|
+
f"session_vars: {key!r} is not in the allow-list of Postgres "
|
|
282
|
+
f"session variables this adapter will set"
|
|
283
|
+
)
|
|
284
|
+
cur.execute("SELECT set_config(%s, %s, true)", [key, str(value)])
|
|
285
|
+
|
|
226
286
|
def commit(self, tx: Any):
|
|
227
287
|
if tx:
|
|
228
288
|
tx.commit()
|
|
@@ -298,7 +358,14 @@ class PostgreSQLAdapter:
|
|
|
298
358
|
# ---------------------------------------------------------------------
|
|
299
359
|
|
|
300
360
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
301
|
-
def insert(
|
|
361
|
+
def insert(
|
|
362
|
+
self,
|
|
363
|
+
table: str,
|
|
364
|
+
data: JsonDict,
|
|
365
|
+
tx: Optional[Any] = None,
|
|
366
|
+
*,
|
|
367
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
368
|
+
) -> JsonDict:
|
|
302
369
|
table = validate_table_name(table)
|
|
303
370
|
for k in data.keys():
|
|
304
371
|
validate_column_name(k)
|
|
@@ -310,6 +377,13 @@ class PostgreSQLAdapter:
|
|
|
310
377
|
own_conn = True
|
|
311
378
|
|
|
312
379
|
try:
|
|
380
|
+
# Applied regardless of who owns `conn` -- see this method's
|
|
381
|
+
# own `session_vars` parameter doc on why a caller-supplied tx
|
|
382
|
+
# still honors it (databaseFactory.py's create() is own_conn;
|
|
383
|
+
# universal_engine's idempotency.py/batches.py call insert-
|
|
384
|
+
# shaped methods on their own already-open tx and need this
|
|
385
|
+
# exact same call to take effect on it).
|
|
386
|
+
self._apply_session_vars(conn, session_vars)
|
|
313
387
|
cursor = conn.cursor()
|
|
314
388
|
columns = ", ".join(data.keys())
|
|
315
389
|
placeholders = ", ".join(["%s"] * len(data))
|
|
@@ -343,6 +417,8 @@ class PostgreSQLAdapter:
|
|
|
343
417
|
limit: Optional[int] = None,
|
|
344
418
|
offset: Optional[int] = None,
|
|
345
419
|
tx: Optional[Any] = None,
|
|
420
|
+
*,
|
|
421
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
346
422
|
) -> List[JsonDict]:
|
|
347
423
|
table = validate_table_name(table)
|
|
348
424
|
conn = tx
|
|
@@ -352,6 +428,7 @@ class PostgreSQLAdapter:
|
|
|
352
428
|
own_conn = True
|
|
353
429
|
|
|
354
430
|
try:
|
|
431
|
+
self._apply_session_vars(conn, session_vars)
|
|
355
432
|
cursor = conn.cursor()
|
|
356
433
|
sql = f"SELECT * FROM {table}"
|
|
357
434
|
params: List[Any] = []
|
|
@@ -411,9 +488,13 @@ class PostgreSQLAdapter:
|
|
|
411
488
|
page_size: int,
|
|
412
489
|
continuation_token: Optional[str] = None,
|
|
413
490
|
tx: Optional[Any] = None,
|
|
491
|
+
*,
|
|
492
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
414
493
|
) -> Tuple[List[JsonDict], Optional[str]]:
|
|
415
494
|
offset = int(continuation_token) if continuation_token else 0
|
|
416
|
-
results = self.select(
|
|
495
|
+
results = self.select(
|
|
496
|
+
table, query, limit=page_size + 1, offset=offset, tx=tx, session_vars=session_vars
|
|
497
|
+
)
|
|
417
498
|
has_more = len(results) > page_size
|
|
418
499
|
if has_more:
|
|
419
500
|
results = results[:page_size]
|
|
@@ -431,6 +512,8 @@ class PostgreSQLAdapter:
|
|
|
431
512
|
entity_id: Union[Any, Lookup],
|
|
432
513
|
data: JsonDict,
|
|
433
514
|
tx: Optional[Any] = None,
|
|
515
|
+
*,
|
|
516
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
434
517
|
) -> JsonDict:
|
|
435
518
|
table = validate_table_name(table)
|
|
436
519
|
for k in data.keys():
|
|
@@ -443,6 +526,7 @@ class PostgreSQLAdapter:
|
|
|
443
526
|
own_conn = True
|
|
444
527
|
|
|
445
528
|
try:
|
|
529
|
+
self._apply_session_vars(conn, session_vars)
|
|
446
530
|
cursor = conn.cursor()
|
|
447
531
|
set_clause = ", ".join([f"{k} = %s" for k in data.keys()])
|
|
448
532
|
params: List[Any] = [self._serialize_value(v) for v in data.values()]
|
|
@@ -485,7 +569,14 @@ class PostgreSQLAdapter:
|
|
|
485
569
|
# ---------------------------------------------------------------------
|
|
486
570
|
|
|
487
571
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
488
|
-
def upsert(
|
|
572
|
+
def upsert(
|
|
573
|
+
self,
|
|
574
|
+
table: str,
|
|
575
|
+
data: JsonDict,
|
|
576
|
+
tx: Optional[Any] = None,
|
|
577
|
+
*,
|
|
578
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
579
|
+
) -> JsonDict:
|
|
489
580
|
table = validate_table_name(table)
|
|
490
581
|
for k in data.keys():
|
|
491
582
|
validate_column_name(k)
|
|
@@ -497,6 +588,7 @@ class PostgreSQLAdapter:
|
|
|
497
588
|
own_conn = True
|
|
498
589
|
|
|
499
590
|
try:
|
|
591
|
+
self._apply_session_vars(conn, session_vars)
|
|
500
592
|
cursor = conn.cursor()
|
|
501
593
|
columns = ", ".join(data.keys())
|
|
502
594
|
placeholders = ", ".join(["%s"] * len(data))
|
|
@@ -548,7 +640,12 @@ class PostgreSQLAdapter:
|
|
|
548
640
|
|
|
549
641
|
@retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
|
|
550
642
|
def delete(
|
|
551
|
-
self,
|
|
643
|
+
self,
|
|
644
|
+
table: str,
|
|
645
|
+
entity_id: Union[Any, Lookup],
|
|
646
|
+
tx: Optional[Any] = None,
|
|
647
|
+
*,
|
|
648
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
552
649
|
) -> JsonDict:
|
|
553
650
|
table = validate_table_name(table)
|
|
554
651
|
conn = tx
|
|
@@ -558,6 +655,7 @@ class PostgreSQLAdapter:
|
|
|
558
655
|
own_conn = True
|
|
559
656
|
|
|
560
657
|
try:
|
|
658
|
+
self._apply_session_vars(conn, session_vars)
|
|
561
659
|
cursor = conn.cursor()
|
|
562
660
|
params: List[Any] = []
|
|
563
661
|
if isinstance(entity_id, dict):
|
|
@@ -742,6 +840,7 @@ class PostgreSQLAdapter:
|
|
|
742
840
|
*,
|
|
743
841
|
fetch: bool = False,
|
|
744
842
|
fetch_one: bool = False,
|
|
843
|
+
session_vars: Optional[Dict[str, str]] = None,
|
|
745
844
|
) -> Union[None, JsonDict, List[JsonDict]]:
|
|
746
845
|
conn = tx
|
|
747
846
|
own_conn = False
|
|
@@ -751,6 +850,15 @@ class PostgreSQLAdapter:
|
|
|
751
850
|
|
|
752
851
|
cursor = None
|
|
753
852
|
try:
|
|
853
|
+
# execute() is the raw-SQL escape hatch a few callers already
|
|
854
|
+
# use for a query with no generic-CRUD equivalent (an atomic
|
|
855
|
+
# claim, a bulk UPDATE, an ORDER BY) -- those are exactly the
|
|
856
|
+
# callers RLS-protected engine-infrastructure tables need this
|
|
857
|
+
# for (see universal_engine's idempotency.py/batches.py),
|
|
858
|
+
# since they run several statements against the same `tx` and
|
|
859
|
+
# only need the session var set once per transaction, same as
|
|
860
|
+
# every other method above.
|
|
861
|
+
self._apply_session_vars(conn, session_vars)
|
|
754
862
|
cursor = conn.cursor()
|
|
755
863
|
self.logger.debug("Executing raw SQL (%d params)", len(params or []))
|
|
756
864
|
exec_params = self._serialize_params(params or [])
|