altcodepro-polydb-python 2.3.18__py3-none-any.whl → 2.3.21__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.
@@ -1,17 +1,19 @@
1
1
  # src/polydb/adapters/postgres.py
2
- import datetime
3
- from decimal import Decimal
4
2
  import os
5
3
  import threading
4
+ import time
6
5
  from typing import Any, Iterator, List, Optional, Tuple, Union
7
6
  import hashlib
8
7
  from contextlib import contextmanager
9
8
  import json
9
+ from decimal import Decimal
10
10
  from datetime import datetime, date
11
11
 
12
12
  import psycopg2.extensions
13
+ from psycopg2 import sql as pg_sql
14
+ from psycopg2.extras import Json
13
15
 
14
- from ..errors import DatabaseError, ConnectionError
16
+ from ..errors import DatabaseError, ConnectionError, InsufficientBalanceError
15
17
  from ..retry import retry
16
18
  from ..utils import validate_table_name, validate_column_name
17
19
  from ..query import QueryBuilder
@@ -25,6 +27,7 @@ class PostgreSQLAdapter:
25
27
  from ..utils import setup_logger
26
28
 
27
29
  self.logger = setup_logger(__name__)
30
+ self._slow_query_ms: float = float(os.getenv("POLYDB_SLOW_QUERY_MS", "1000"))
28
31
  self.connection_string = connection_string or os.getenv(
29
32
  "POSTGRES_CONNECTION_STRING",
30
33
  os.getenv("POSTGRES_URL", ""),
@@ -40,45 +43,29 @@ class PostgreSQLAdapter:
40
43
  # ---------------------------------------------------------------------
41
44
 
42
45
  def _is_idle(self, conn) -> bool:
43
- """True iff the connection is not inside a (possibly aborted) transaction."""
44
46
  try:
45
47
  return conn.info.transaction_status == psycopg2.extensions.TRANSACTION_STATUS_IDLE
46
48
  except Exception:
47
49
  return False
48
50
 
49
51
  def _drain_transaction(self, conn) -> None:
50
- """Force the connection back to IDLE so it's safe to change session
51
- settings (e.g. autocommit). Safe to call when already idle."""
52
52
  if self._is_idle(conn):
53
53
  return
54
54
  try:
55
55
  conn.rollback()
56
56
  except Exception:
57
- # Last resort: caller will surface a real error on next use.
58
57
  pass
59
58
 
60
59
  def _ping_connection(self, conn) -> bool:
61
- """Test if connection is still alive.
62
-
63
- psycopg2 auto-starts a transaction on the first statement after
64
- commit/rollback, so we MUST rollback after the SELECT 1 — otherwise
65
- the next caller inherits an active transaction and any attempt to
66
- toggle autocommit raises ``set_session cannot be used inside a
67
- transaction``.
68
- """
69
60
  try:
70
61
  with conn.cursor() as cur:
71
62
  cur.execute("SELECT 1")
72
- # Clean up the auto-started transaction so the connection
73
- # leaves this method in IDLE state.
74
63
  try:
75
64
  conn.rollback()
76
65
  except Exception:
77
66
  pass
78
67
  return True
79
68
  except Exception:
80
- # Best-effort cleanup on a failed ping. The connection is
81
- # almost certainly broken; the caller will close it.
82
69
  try:
83
70
  conn.rollback()
84
71
  except Exception:
@@ -90,7 +77,6 @@ class PostgreSQLAdapter:
90
77
  import psycopg2.pool
91
78
  from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
92
79
 
93
- # Enhance connection string with better Azure settings
94
80
  dsn = self.connection_string
95
81
  if "postgresql://" in dsn:
96
82
  parsed = urlparse(dsn)
@@ -105,17 +91,37 @@ class PostgreSQLAdapter:
105
91
  parsed = parsed._replace(query=new_query)
106
92
  dsn = urlunparse(parsed)
107
93
 
94
+ _maxconn = int(os.getenv("POSTGRES_MAX_CONNECTIONS", "100"))
108
95
  with self._lock:
109
96
  if not self._pool:
110
97
  self._pool = psycopg2.pool.ThreadedConnectionPool(
111
98
  minconn=int(os.getenv("POSTGRES_MIN_CONNECTIONS", "5")),
112
- maxconn=int(os.getenv("POSTGRES_MAX_CONNECTIONS", "30")),
99
+ maxconn=_maxconn,
113
100
  dsn=dsn,
114
101
  )
115
- self.logger.info("PostgreSQL pool initialized with Azure optimizations")
102
+ self.logger.info(
103
+ "PostgreSQL pool initialized: min=%s max=%s",
104
+ os.getenv("POSTGRES_MIN_CONNECTIONS", "5"),
105
+ _maxconn,
106
+ )
116
107
  except Exception as e:
117
108
  raise ConnectionError(f"Failed to initialize PostgreSQL pool: {str(e)}")
118
109
 
110
+ def _log_pool_utilization(self) -> None:
111
+ if not self._pool:
112
+ return
113
+ try:
114
+ maxconn = int(os.getenv("POSTGRES_MAX_CONNECTIONS", "100"))
115
+ used_count = len(getattr(self._pool, "_used", {}))
116
+ pct = (used_count / maxconn * 100) if maxconn else 0
117
+ if pct > 80:
118
+ self.logger.warning(
119
+ "PostgreSQL pool utilization: %d/%d connections in use (%.0f%%)",
120
+ used_count, maxconn, pct,
121
+ )
122
+ except Exception:
123
+ pass
124
+
119
125
  def _get_connection(self) -> Any:
120
126
  if not self._pool:
121
127
  self._initialize_pool()
@@ -123,16 +129,13 @@ class PostgreSQLAdapter:
123
129
  try:
124
130
  conn = self._pool.getconn() # type: ignore
125
131
 
126
- # Critical: Validate connection for Azure transient issues.
127
- # _ping_connection rolls back its own SELECT 1 so the connection
128
- # is returned to callers in IDLE state.
129
132
  if not self._ping_connection(conn):
130
133
  self.logger.warning("Stale connection detected from pool, closing and retrying")
131
134
  self._pool.putconn(conn, close=True) # type: ignore
132
- conn = self._pool.getconn() # type: ignore # Get fresh connection
133
- # New connection might still have been used before — make sure it's idle.
135
+ conn = self._pool.getconn() # type: ignore
134
136
  self._drain_transaction(conn)
135
137
 
138
+ self._log_pool_utilization()
136
139
  return conn
137
140
 
138
141
  except Exception as e:
@@ -140,51 +143,66 @@ class PostgreSQLAdapter:
140
143
  raise ConnectionError(f"Could not obtain database connection: {e}") from e
141
144
 
142
145
  def _return_connection(self, conn: Any):
143
- """Return a connection to the pool, defensively draining any
144
- leftover transaction state. Belt-and-suspenders: every operation
145
- in this adapter already commits/rolls-back before returning, but
146
- if any path ever forgets, the pool still gets a clean connection.
147
- """
148
146
  if self._pool and conn:
149
147
  self._drain_transaction(conn)
150
148
  self._pool.putconn(conn)
151
149
 
150
+ # ---------------------------------------------------------------------
151
+ # QUERY TIMING HELPER
152
+ # ---------------------------------------------------------------------
153
+
154
+ def _timed_execute(
155
+ self,
156
+ cursor: Any,
157
+ sql: str,
158
+ params: Any,
159
+ *,
160
+ operation: str = "execute",
161
+ table: str = "",
162
+ ) -> float:
163
+ t0 = time.perf_counter()
164
+ cursor.execute(sql, params)
165
+ duration_ms = (time.perf_counter() - t0) * 1000.0
166
+
167
+ self.logger.debug(
168
+ "SQL executed",
169
+ extra={"operation": operation, "table": table, "duration_ms": round(duration_ms, 3)},
170
+ )
171
+
172
+ if duration_ms > self._slow_query_ms:
173
+ self.logger.warning(
174
+ "Slow query detected: operation=%s table=%s duration_ms=%.1f threshold=%.1f",
175
+ operation, table, duration_ms, self._slow_query_ms,
176
+ )
177
+
178
+ return duration_ms
179
+
152
180
  # ---------------------------------------------------------------------
153
181
  # TRANSACTIONS
154
182
  # ---------------------------------------------------------------------
183
+
155
184
  def reset_pool(self):
156
- """Reset the entire pool (call during startup or after major failures)"""
157
185
  with self._lock:
158
186
  if self._pool:
159
187
  try:
160
188
  self._pool.closeall()
161
- except:
189
+ except Exception:
162
190
  pass
163
191
  self._pool = None
164
192
  self._initialize_pool()
165
193
 
166
194
  def begin_transaction(self) -> Any:
167
- """Begin a transaction and return the connection handle.
168
-
169
- Defensively drains any leftover transaction state on the pooled
170
- connection before toggling autocommit. Without this, a connection
171
- that's still in TRANSACTION_STATUS_INTRANS (from a previous user
172
- or from the pool's connection check) causes psycopg2 to raise
173
- ``set_session cannot be used inside a transaction``.
174
- """
175
195
  conn = self._get_connection()
176
196
  self._drain_transaction(conn)
177
197
  conn.autocommit = False
178
198
  return conn
179
199
 
180
200
  def commit(self, tx: Any):
181
- """Commit the transaction using the provided connection."""
182
201
  if tx:
183
202
  tx.commit()
184
203
  self._return_connection(tx)
185
204
 
186
205
  def rollback(self, tx: Any):
187
- """Rollback the transaction using the provided connection."""
188
206
  if tx:
189
207
  tx.rollback()
190
208
  self._return_connection(tx)
@@ -192,11 +210,8 @@ class PostgreSQLAdapter:
192
210
  # ---------------------------------------------------------------------
193
211
  # JSON HELPERS
194
212
  # ---------------------------------------------------------------------
213
+
195
214
  def _json_safe(self, obj: Any):
196
- """
197
- Ensure JSON serialization never fails.
198
- Used only for Json() wrapping.
199
- """
200
215
  if isinstance(obj, datetime):
201
216
  return obj.isoformat()
202
217
  if isinstance(obj, Decimal):
@@ -205,71 +220,44 @@ class PostgreSQLAdapter:
205
220
  return str(obj)
206
221
  if isinstance(obj, dict):
207
222
  return {k: self._json_safe(v) for k, v in obj.items()}
208
- if isinstance(obj, list):
223
+ if isinstance(obj, (list, tuple)):
209
224
  return [self._json_safe(v) for v in obj]
210
225
  return obj
211
226
 
212
227
  def _serialize_value(self, v: Any) -> Any:
213
- """
214
- Make outgoing values safe for psycopg2 across mixed column types.
215
-
216
- Rules:
217
- None / empty list / empty dict -> None (becomes NULL on any column)
218
- list of primitives (str/int/...) -> native list (psycopg2 maps to TEXT[]/INT[])
219
- list containing dicts -> Json(list) (for JSONB columns)
220
- dict -> Json(dict)
221
- datetime/date -> native
222
- Decimal -> float
223
- everything else -> as-is
224
- """
225
- # NULL-ify empties so they're valid for TEXT[], JSONB, and plain columns alike.
226
- from psycopg2.extras import Json
227
-
228
228
  if v is None:
229
229
  return None
230
- if isinstance(v, (list, tuple)) and len(v) == 0:
231
- return None
232
- if isinstance(v, dict) and len(v) == 0:
233
- return None
230
+ if isinstance(v, (dict, list, tuple)):
231
+ return Json(self._json_safe(v))
232
+ if isinstance(v, (datetime, date)):
233
+ return v
234
+ if isinstance(v, Decimal):
235
+ return float(v)
236
+ return v
234
237
 
235
- # Dict -> JSONB
238
+ def _serialize_param(self, v: Any) -> Any:
239
+ if v is None:
240
+ return None
236
241
  if isinstance(v, dict):
237
242
  return Json(self._json_safe(v))
238
-
239
- # List: route by element type.
240
243
  if isinstance(v, (list, tuple)):
241
- v = list(v)
242
- # If ANY element is a dict, treat as JSON payload (for JSONB columns).
243
- if any(isinstance(x, dict) for x in v):
244
- return Json(v)
245
- # If ALL elements are primitives, send as native list for TEXT[]/INT[].
246
- if all(isinstance(x, (str, int, float, bool, type(None))) for x in v):
247
- return v
248
- # Mixed / nested -> safest is JSONB
249
- return Json(v)
250
-
251
- # Datetime / date
244
+ seq = list(v)
245
+ if any(isinstance(x, dict) for x in seq):
246
+ return Json(self._json_safe(seq))
247
+ return seq
252
248
  if isinstance(v, (datetime, date)):
253
249
  return v
254
-
255
- # Decimal
256
250
  if isinstance(v, Decimal):
257
251
  return float(v)
258
-
259
252
  return v
260
253
 
261
254
  def _serialize_params(self, params: List[Any]) -> List[Any]:
262
- return [self._serialize_value(p) for p in params]
255
+ return [self._serialize_param(p) for p in params]
263
256
 
264
257
  def _deserialize_row(self, row: JsonDict) -> JsonDict:
265
- """
266
- Postgres JSON/JSONB often comes back as dict already depending on driver config.
267
- If it comes as a string, try json.loads safely.
268
- """
269
258
  for k, v in list(row.items()):
270
259
  if isinstance(v, str):
271
260
  s = v.strip()
272
- # quick cheap check to avoid parsing normal strings
273
261
  if (s.startswith("{") and s.endswith("}")) or (
274
262
  s.startswith("[") and s.endswith("]")
275
263
  ):
@@ -297,24 +285,18 @@ class PostgreSQLAdapter:
297
285
 
298
286
  try:
299
287
  cursor = conn.cursor()
300
-
301
288
  columns = ", ".join(data.keys())
302
289
  placeholders = ", ".join(["%s"] * len(data))
303
290
  query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
304
-
305
291
  values = [self._serialize_value(v) for v in data.values()]
306
- cursor.execute(query, values)
307
-
292
+ self._timed_execute(cursor, query, values, operation="insert", table=table)
308
293
  result_row = cursor.fetchone()
309
294
  columns_list = [desc[0] for desc in cursor.description]
310
295
  result = dict(zip(columns_list, result_row))
311
-
312
296
  if own_conn:
313
297
  conn.commit()
314
-
315
298
  cursor.close()
316
299
  return self._deserialize_row(result)
317
-
318
300
  except Exception as e:
319
301
  if own_conn:
320
302
  conn.rollback()
@@ -345,7 +327,6 @@ class PostgreSQLAdapter:
345
327
 
346
328
  try:
347
329
  cursor = conn.cursor()
348
-
349
330
  sql = f"SELECT * FROM {table}"
350
331
  params: List[Any] = []
351
332
 
@@ -353,8 +334,6 @@ class PostgreSQLAdapter:
353
334
  where_parts: List[str] = []
354
335
  for k, v in query.items():
355
336
  validate_column_name(k)
356
-
357
- # IMPORTANT: None must be "IS NULL" not "= %s"
358
337
  if v is None:
359
338
  where_parts.append(f"{k} IS NULL")
360
339
  elif isinstance(v, (list, tuple)):
@@ -364,7 +343,6 @@ class PostgreSQLAdapter:
364
343
  else:
365
344
  where_parts.append(f"{k} = %s")
366
345
  params.append(v)
367
-
368
346
  if where_parts:
369
347
  sql += " WHERE " + " AND ".join(where_parts)
370
348
 
@@ -375,14 +353,14 @@ class PostgreSQLAdapter:
375
353
  sql += " OFFSET %s"
376
354
  params.append(offset)
377
355
 
378
- cursor.execute(sql, self._serialize_params(params))
356
+ self._timed_execute(
357
+ cursor, sql, self._serialize_params(params), operation="select", table=table
358
+ )
379
359
  columns = [desc[0] for desc in cursor.description]
380
360
  results = [self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()]
381
361
  cursor.close()
382
-
383
362
  if own_conn:
384
363
  conn.commit()
385
-
386
364
  return results
387
365
  except Exception as e:
388
366
  if own_conn:
@@ -410,11 +388,9 @@ class PostgreSQLAdapter:
410
388
  ) -> Tuple[List[JsonDict], Optional[str]]:
411
389
  offset = int(continuation_token) if continuation_token else 0
412
390
  results = self.select(table, query, limit=page_size + 1, offset=offset, tx=tx)
413
-
414
391
  has_more = len(results) > page_size
415
392
  if has_more:
416
393
  results = results[:page_size]
417
-
418
394
  next_token = str(offset + page_size) if has_more else None
419
395
  return results, next_token
420
396
 
@@ -442,7 +418,6 @@ class PostgreSQLAdapter:
442
418
 
443
419
  try:
444
420
  cursor = conn.cursor()
445
-
446
421
  set_clause = ", ".join([f"{k} = %s" for k in data.keys()])
447
422
  params: List[Any] = [self._serialize_value(v) for v in data.values()]
448
423
 
@@ -454,25 +429,21 @@ class PostgreSQLAdapter:
454
429
  where_parts.append(f"{k} IS NULL")
455
430
  else:
456
431
  where_parts.append(f"{k} = %s")
457
- params.append(self._serialize_value(v))
432
+ params.append(self._serialize_param(v))
458
433
  where_clause = " AND ".join(where_parts)
459
434
  else:
460
435
  where_clause = "id = %s"
461
436
  params.append(entity_id)
462
437
 
463
438
  query = f"UPDATE {table} SET {set_clause} WHERE {where_clause} RETURNING *"
464
- cursor.execute(query, params)
465
-
439
+ self._timed_execute(cursor, query, params, operation="update", table=table)
466
440
  result_row = cursor.fetchone()
467
441
  if not result_row:
468
442
  raise DatabaseError("No rows updated")
469
-
470
443
  columns = [desc[0] for desc in cursor.description]
471
444
  result = dict(zip(columns, result_row))
472
-
473
445
  if own_conn:
474
446
  conn.commit()
475
-
476
447
  cursor.close()
477
448
  return self._deserialize_row(result)
478
449
  except Exception as e:
@@ -501,10 +472,8 @@ class PostgreSQLAdapter:
501
472
 
502
473
  try:
503
474
  cursor = conn.cursor()
504
-
505
475
  columns = ", ".join(data.keys())
506
476
  placeholders = ", ".join(["%s"] * len(data))
507
-
508
477
  conflict_columns = ["id"] if "id" in data else list(data.keys())[:1]
509
478
  update_fields = [k for k in data.keys() if k not in conflict_columns]
510
479
 
@@ -521,14 +490,10 @@ class PostgreSQLAdapter:
521
490
  {on_conflict}
522
491
  RETURNING *
523
492
  """
524
-
525
493
  values = [self._serialize_value(v) for v in data.values()]
526
- cursor.execute(query, values)
527
-
494
+ self._timed_execute(cursor, query, values, operation="upsert", table=table)
528
495
  result_row = cursor.fetchone()
529
496
  if not result_row:
530
- # DO NOTHING case: fetch existing row (best effort)
531
- # If conflict is on id, we can read it back.
532
497
  if "id" in conflict_columns and "id" in data:
533
498
  cursor.execute(f"SELECT * FROM {table} WHERE id = %s", [data["id"]])
534
499
  result_row = cursor.fetchone()
@@ -539,10 +504,8 @@ class PostgreSQLAdapter:
539
504
 
540
505
  columns_list = [desc[0] for desc in cursor.description]
541
506
  result = dict(zip(columns_list, result_row))
542
-
543
507
  if own_conn:
544
508
  conn.commit()
545
-
546
509
  cursor.close()
547
510
  return self._deserialize_row(result)
548
511
  except Exception as e:
@@ -570,7 +533,6 @@ class PostgreSQLAdapter:
570
533
 
571
534
  try:
572
535
  cursor = conn.cursor()
573
-
574
536
  params: List[Any] = []
575
537
  if isinstance(entity_id, dict):
576
538
  where_parts: List[str] = []
@@ -580,25 +542,21 @@ class PostgreSQLAdapter:
580
542
  where_parts.append(f"{k} IS NULL")
581
543
  else:
582
544
  where_parts.append(f"{k} = %s")
583
- params.append(self._serialize_value(v))
545
+ params.append(self._serialize_param(v))
584
546
  where_clause = " AND ".join(where_parts)
585
547
  else:
586
548
  where_clause = "id = %s"
587
549
  params.append(entity_id)
588
550
 
589
551
  query = f"DELETE FROM {table} WHERE {where_clause} RETURNING *"
590
- cursor.execute(query, params)
552
+ self._timed_execute(cursor, query, params, operation="delete", table=table)
591
553
  result_row = cursor.fetchone()
592
-
593
554
  if not result_row:
594
555
  raise DatabaseError("No rows deleted")
595
-
596
556
  columns = [desc[0] for desc in cursor.description]
597
557
  result = dict(zip(columns, result_row))
598
-
599
558
  if own_conn:
600
559
  conn.commit()
601
-
602
560
  cursor.close()
603
561
  return self._deserialize_row(result)
604
562
  except Exception as e:
@@ -617,9 +575,7 @@ class PostgreSQLAdapter:
617
575
  def query_linq(
618
576
  self, table: str, builder: QueryBuilder, tx: Optional[Any] = None
619
577
  ) -> Union[List[JsonDict], int]:
620
-
621
578
  table = validate_table_name(table)
622
-
623
579
  conn = tx
624
580
  own_conn = False
625
581
  if not conn:
@@ -628,117 +584,67 @@ class PostgreSQLAdapter:
628
584
 
629
585
  try:
630
586
  cursor = conn.cursor()
631
-
632
- # ------------------------------------------------
633
- # SELECT clause
634
- # ------------------------------------------------
635
-
636
587
  if builder.count_only:
637
588
  sql = f"SELECT COUNT(*) FROM {table}"
638
-
639
589
  elif builder.selected_fields:
640
590
  for f in builder.selected_fields:
641
591
  validate_column_name(f)
642
-
643
592
  fields = ", ".join(builder.selected_fields)
644
-
645
593
  if builder.distinct_flag:
646
594
  sql = f"SELECT DISTINCT {fields} FROM {table}"
647
595
  else:
648
596
  sql = f"SELECT {fields} FROM {table}"
649
-
650
597
  else:
651
598
  sql = f"SELECT * FROM {table}"
652
599
 
653
600
  params: List[Any] = []
654
-
655
- # ------------------------------------------------
656
- # WHERE
657
- # ------------------------------------------------
658
-
659
601
  where_clause, where_params = builder.to_sql_where()
660
-
661
602
  if where_clause:
662
603
  sql += f" WHERE {where_clause}"
663
604
  params.extend(where_params)
664
605
 
665
- # ------------------------------------------------
666
- # GROUP BY
667
- # ------------------------------------------------
668
-
669
606
  if builder.group_by_fields:
670
-
671
607
  for f in builder.group_by_fields:
672
608
  validate_column_name(f)
673
-
674
609
  sql += f" GROUP BY {', '.join(builder.group_by_fields)}"
675
610
 
676
- # ------------------------------------------------
677
- # ORDER BY
678
- # ------------------------------------------------
679
-
680
611
  if builder.order_by_fields:
681
-
682
612
  order_parts = []
683
-
684
613
  for field, desc in builder.order_by_fields:
685
-
686
614
  validate_column_name(field)
687
-
688
615
  direction = "DESC" if desc else "ASC"
689
-
690
616
  order_parts.append(f"{field} {direction}")
691
-
692
617
  sql += f" ORDER BY {', '.join(order_parts)}"
693
618
 
694
- # ------------------------------------------------
695
- # LIMIT
696
- # ------------------------------------------------
697
-
698
619
  if builder.take_count is not None:
699
620
  sql += " LIMIT %s"
700
621
  params.append(builder.take_count)
701
622
 
702
- # ------------------------------------------------
703
- # OFFSET
704
- # ------------------------------------------------
705
-
706
623
  if builder.skip_count:
707
624
  sql += " OFFSET %s"
708
625
  params.append(builder.skip_count)
709
626
 
710
- # ------------------------------------------------
711
- # EXECUTE
712
- # ------------------------------------------------
713
-
714
- cursor.execute(sql, self._serialize_params(params))
627
+ self._timed_execute(
628
+ cursor, sql, self._serialize_params(params), operation="query_linq", table=table
629
+ )
715
630
 
716
631
  if builder.count_only:
717
632
  result = cursor.fetchone()[0]
718
-
719
633
  else:
720
634
  columns = [desc[0] for desc in cursor.description]
721
-
722
635
  result = [
723
636
  self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()
724
637
  ]
725
638
 
726
639
  cursor.close()
727
-
728
640
  if own_conn:
729
641
  conn.commit()
730
-
731
642
  return result
732
-
733
643
  except Exception as e:
734
-
735
644
  if own_conn:
736
645
  conn.rollback()
737
-
738
646
  raise DatabaseError(f"LINQ query failed: {str(e)}")
739
-
740
647
  finally:
741
-
742
648
  if own_conn and conn:
743
649
  self._return_connection(conn)
744
650
 
@@ -765,10 +671,10 @@ class PostgreSQLAdapter:
765
671
  cursor = None
766
672
  try:
767
673
  cursor = conn.cursor()
768
- self.logger.debug("Executing raw SQL: %s", sql)
769
-
674
+ # Log operation shape only, never parameter values
675
+ self.logger.debug("Executing raw SQL (%d params)", len(params or []))
770
676
  exec_params = self._serialize_params(params or [])
771
- cursor.execute(sql, exec_params)
677
+ self._timed_execute(cursor, sql, exec_params, operation="execute", table="")
772
678
 
773
679
  if fetch_one:
774
680
  row = cursor.fetchone()
@@ -791,7 +697,6 @@ class PostgreSQLAdapter:
791
697
  if own_conn:
792
698
  conn.commit()
793
699
  return None
794
-
795
700
  except Exception as e:
796
701
  if own_conn:
797
702
  try:
@@ -799,7 +704,6 @@ class PostgreSQLAdapter:
799
704
  except Exception:
800
705
  pass
801
706
  raise DatabaseError(f"Execute failed: {str(e)}")
802
-
803
707
  finally:
804
708
  if cursor:
805
709
  try:
@@ -809,17 +713,234 @@ class PostgreSQLAdapter:
809
713
  if own_conn and conn:
810
714
  self._return_connection(conn)
811
715
 
716
+ # ---------------------------------------------------------------------
717
+ # ATOMIC OPERATIONS
718
+ # ---------------------------------------------------------------------
719
+
720
+ def atomic_decrement_if_sufficient(
721
+ self,
722
+ table: str,
723
+ balance_field: str,
724
+ amount: Any,
725
+ where_field: str,
726
+ where_value: Any,
727
+ *,
728
+ tx: Optional[Any] = None,
729
+ ) -> JsonDict:
730
+ table = validate_table_name(table)
731
+ validate_column_name(balance_field)
732
+ validate_column_name(where_field)
733
+
734
+ conn = tx
735
+ own_conn = False
736
+ if not conn:
737
+ conn = self._get_connection()
738
+ own_conn = True
739
+
740
+ try:
741
+ cursor = conn.cursor()
742
+ sql = (
743
+ f"UPDATE {table} "
744
+ f"SET {balance_field} = {balance_field} - %s "
745
+ f"WHERE {where_field} = %s AND {balance_field} >= %s "
746
+ f"RETURNING *"
747
+ )
748
+ cursor.execute(sql, [amount, where_value, amount])
749
+ result_row = cursor.fetchone()
750
+
751
+ if not result_row:
752
+ if own_conn:
753
+ conn.rollback()
754
+ raise InsufficientBalanceError(
755
+ f"Insufficient balance in {table}.{balance_field} for {where_field}={where_value!r}"
756
+ )
757
+
758
+ columns = [desc[0] for desc in cursor.description]
759
+ result = dict(zip(columns, result_row))
760
+ if own_conn:
761
+ conn.commit()
762
+ cursor.close()
763
+ return self._deserialize_row(result)
764
+ except InsufficientBalanceError:
765
+ raise
766
+ except Exception as e:
767
+ if own_conn:
768
+ try:
769
+ conn.rollback()
770
+ except Exception:
771
+ pass
772
+ raise DatabaseError(f"atomic_decrement_if_sufficient failed: {str(e)}")
773
+ finally:
774
+ if own_conn and conn:
775
+ self._return_connection(conn)
776
+
777
+ def atomic_set_if(
778
+ self,
779
+ table: str,
780
+ id_field: str,
781
+ id_value: Any,
782
+ field: str,
783
+ value: Any,
784
+ expected: Any,
785
+ *,
786
+ tx: Optional[Any] = None,
787
+ ) -> Optional[JsonDict]:
788
+ table = validate_table_name(table)
789
+ validate_column_name(field)
790
+ validate_column_name(id_field)
791
+
792
+ conn = tx
793
+ own_conn = not conn
794
+ if own_conn:
795
+ conn = self._get_connection()
796
+ try:
797
+ cursor = conn.cursor()
798
+ sql = (
799
+ f"UPDATE {table} SET {field} = %s "
800
+ f"WHERE {id_field} = %s AND {field} = %s RETURNING *"
801
+ )
802
+ cursor.execute(sql, [value, id_value, expected])
803
+ row = cursor.fetchone()
804
+ if own_conn:
805
+ conn.commit()
806
+ cursor.close()
807
+ if row is None:
808
+ return None
809
+ cols = [d[0] for d in cursor.description]
810
+ return self._deserialize_row(dict(zip(cols, row)))
811
+ except Exception as e:
812
+ if own_conn:
813
+ try:
814
+ conn.rollback()
815
+ except Exception:
816
+ pass
817
+ raise DatabaseError(f"atomic_set_if failed: {e}") from e
818
+ finally:
819
+ if own_conn and conn:
820
+ self._return_connection(conn)
821
+
822
+ def atomic_max(
823
+ self,
824
+ table: str,
825
+ id_field: str,
826
+ id_value: Any,
827
+ field: str,
828
+ value: Any,
829
+ *,
830
+ tx: Optional[Any] = None,
831
+ ) -> JsonDict:
832
+ table = validate_table_name(table)
833
+ validate_column_name(field)
834
+ validate_column_name(id_field)
835
+
836
+ conn = tx
837
+ own_conn = not conn
838
+ if own_conn:
839
+ conn = self._get_connection()
840
+ try:
841
+ cursor = conn.cursor()
842
+ cursor.execute(
843
+ f"UPDATE {table} SET {field} = GREATEST({field}, %s) WHERE {id_field} = %s RETURNING *",
844
+ [value, id_value],
845
+ )
846
+ row = cursor.fetchone()
847
+ if own_conn:
848
+ conn.commit()
849
+ cursor.close()
850
+ if row is None:
851
+ raise DatabaseError(f"atomic_max: row not found {id_field}={id_value!r}")
852
+ cols = [d[0] for d in cursor.description]
853
+ return self._deserialize_row(dict(zip(cols, row)))
854
+ except DatabaseError:
855
+ raise
856
+ except Exception as e:
857
+ if own_conn:
858
+ try:
859
+ conn.rollback()
860
+ except Exception:
861
+ pass
862
+ raise DatabaseError(f"atomic_max failed: {e}") from e
863
+ finally:
864
+ if own_conn and conn:
865
+ self._return_connection(conn)
866
+
867
+ def atomic_min(
868
+ self,
869
+ table: str,
870
+ id_field: str,
871
+ id_value: Any,
872
+ field: str,
873
+ value: Any,
874
+ *,
875
+ tx: Optional[Any] = None,
876
+ ) -> JsonDict:
877
+ table = validate_table_name(table)
878
+ validate_column_name(field)
879
+ validate_column_name(id_field)
880
+
881
+ conn = tx
882
+ own_conn = not conn
883
+ if own_conn:
884
+ conn = self._get_connection()
885
+ try:
886
+ cursor = conn.cursor()
887
+ cursor.execute(
888
+ f"UPDATE {table} SET {field} = LEAST({field}, %s) WHERE {id_field} = %s RETURNING *",
889
+ [value, id_value],
890
+ )
891
+ row = cursor.fetchone()
892
+ if own_conn:
893
+ conn.commit()
894
+ cursor.close()
895
+ if row is None:
896
+ raise DatabaseError(f"atomic_min: row not found {id_field}={id_value!r}")
897
+ cols = [d[0] for d in cursor.description]
898
+ return self._deserialize_row(dict(zip(cols, row)))
899
+ except DatabaseError:
900
+ raise
901
+ except Exception as e:
902
+ if own_conn:
903
+ try:
904
+ conn.rollback()
905
+ except Exception:
906
+ pass
907
+ raise DatabaseError(f"atomic_min failed: {e}") from e
908
+ finally:
909
+ if own_conn and conn:
910
+ self._return_connection(conn)
911
+
912
+ # ---------------------------------------------------------------------
913
+ # SAVEPOINTS
914
+ # Security: use pg_sql.Identifier to prevent SQL injection via savepoint names.
915
+ # ---------------------------------------------------------------------
916
+
917
+ def begin_savepoint(self, name: str, tx: Any) -> None:
918
+ try:
919
+ with tx.cursor() as cur:
920
+ cur.execute(pg_sql.SQL("SAVEPOINT {}").format(pg_sql.Identifier(name)))
921
+ except Exception as e:
922
+ raise DatabaseError(f"begin_savepoint({name!r}) failed: {str(e)}")
923
+
924
+ def rollback_to_savepoint(self, name: str, tx: Any) -> None:
925
+ try:
926
+ with tx.cursor() as cur:
927
+ cur.execute(pg_sql.SQL("ROLLBACK TO SAVEPOINT {}").format(pg_sql.Identifier(name)))
928
+ except Exception as e:
929
+ raise DatabaseError(f"rollback_to_savepoint({name!r}) failed: {str(e)}")
930
+
931
+ def release_savepoint(self, name: str, tx: Any) -> None:
932
+ try:
933
+ with tx.cursor() as cur:
934
+ cur.execute(pg_sql.SQL("RELEASE SAVEPOINT {}").format(pg_sql.Identifier(name)))
935
+ except Exception as e:
936
+ raise DatabaseError(f"release_savepoint({name!r}) failed: {str(e)}")
937
+
812
938
  # ---------------------------------------------------------------------
813
939
  # DISTRIBUTED LOCK
814
940
  # ---------------------------------------------------------------------
815
941
 
816
942
  @contextmanager
817
943
  def distributed_lock(self, lock_name: str) -> Iterator[None]:
818
- """
819
- PostgreSQL advisory lock (session scoped).
820
- - Always unlock before returning the pooled connection.
821
- - Never wrap exceptions raised by the user block.
822
- """
823
944
  conn = None
824
945
  cursor = None
825
946
  lock_id = int(hashlib.sha256(lock_name.encode()).hexdigest(), 16) % (2**63)
@@ -827,8 +948,6 @@ class PostgreSQLAdapter:
827
948
  try:
828
949
  conn = self._get_connection()
829
950
  cursor = conn.cursor()
830
-
831
- # Acquire lock (DB op) — if this fails, it's a DatabaseError
832
951
  try:
833
952
  cursor.execute("SELECT pg_advisory_lock(%s);", (lock_id,))
834
953
  self.logger.debug("Acquired distributed lock: %s", lock_name)
@@ -836,21 +955,15 @@ class PostgreSQLAdapter:
836
955
  raise DatabaseError(f"Distributed lock acquire failed: {e}") from e
837
956
 
838
957
  try:
839
- # User code runs here. If it raises, it MUST propagate unchanged.
840
958
  yield
841
959
  finally:
842
- # Release lock (DB op). Always attempted.
843
960
  try:
844
961
  cursor.execute("SELECT pg_advisory_unlock(%s);", (lock_id,))
845
962
  self.logger.debug("Released distributed lock: %s", lock_name)
846
963
  except Exception as e:
847
- # IMPORTANT: don't mask user exceptions.
848
- # If unlock fails, raise DatabaseError only if user block didn't already fail.
849
- # Easiest safe behavior: just log and continue.
850
964
  self.logger.exception(
851
965
  "Distributed lock release failed for %s: %s", lock_name, e
852
966
  )
853
-
854
967
  finally:
855
968
  if cursor:
856
969
  try: