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.
@@ -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
- self._initialize_pool()
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(self, table: str, data: JsonDict, tx: Optional[Any] = None) -> JsonDict:
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(table, query, limit=page_size + 1, offset=offset, tx=tx)
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(self, table: str, data: JsonDict, tx: Optional[Any] = None) -> JsonDict:
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, table: str, entity_id: Union[Any, Lookup], tx: Optional[Any] = None
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 [])