altcodepro-polydb-python 2.2.2__py3-none-any.whl → 2.2.4__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.
Files changed (38) hide show
  1. altcodepro_polydb_python-2.2.4.dist-info/METADATA +489 -0
  2. altcodepro_polydb_python-2.2.4.dist-info/RECORD +57 -0
  3. {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/WHEEL +1 -1
  4. polydb/__init__.py +2 -2
  5. polydb/adapters/AzureBlobStorageAdapter.py +146 -41
  6. polydb/adapters/AzureFileStorageAdapter.py +148 -43
  7. polydb/adapters/AzureQueueAdapter.py +96 -34
  8. polydb/adapters/AzureTableStorageAdapter.py +462 -119
  9. polydb/adapters/BlockchainBlobAdapter.py +111 -0
  10. polydb/adapters/BlockchainKVAdapter.py +152 -0
  11. polydb/adapters/BlockchainQueueAdapter.py +116 -0
  12. polydb/adapters/DynamoDBAdapter.py +463 -176
  13. polydb/adapters/FirestoreAdapter.py +320 -148
  14. polydb/adapters/GCPPubSubAdapter.py +217 -0
  15. polydb/adapters/GCPStorageAdapter.py +184 -39
  16. polydb/adapters/MongoDBAdapter.py +159 -39
  17. polydb/adapters/PostgreSQLAdapter.py +285 -83
  18. polydb/adapters/S3Adapter.py +172 -35
  19. polydb/adapters/S3CompatibleAdapter.py +62 -8
  20. polydb/adapters/SQSAdapter.py +121 -44
  21. polydb/adapters/VercelBlobAdapter.py +196 -0
  22. polydb/adapters/VercelKVAdapter.py +275 -283
  23. polydb/adapters/VercelQueueAdapter.py +61 -0
  24. polydb/audit/AuditStorage.py +1 -1
  25. polydb/base/NoSQLKVAdapter.py +113 -101
  26. polydb/base/ObjectStorageAdapter.py +42 -6
  27. polydb/base/QueueAdapter.py +2 -2
  28. polydb/base/SharedFilesAdapter.py +2 -2
  29. polydb/cloudDatabaseFactory.py +200 -0
  30. polydb/databaseFactory.py +434 -101
  31. polydb/models.py +63 -1
  32. polydb/query.py +111 -42
  33. altcodepro_polydb_python-2.2.2.dist-info/METADATA +0 -379
  34. altcodepro_polydb_python-2.2.2.dist-info/RECORD +0 -52
  35. polydb/adapters/PubSubAdapter.py +0 -85
  36. polydb/factory.py +0 -107
  37. {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/licenses/LICENSE +0 -0
  38. {altcodepro_polydb_python-2.2.2.dist-info → altcodepro_polydb_python-2.2.4.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,14 @@
1
1
  # src/polydb/adapters/postgres.py
2
+ import datetime
3
+ from decimal import Decimal
2
4
  import os
3
5
  import threading
4
- from typing import Any, List, Optional, Tuple, Union
6
+ from typing import Any, Iterator, List, Optional, Tuple, Union
5
7
  import hashlib
6
8
  from contextlib import contextmanager
9
+ import json
10
+ from datetime import datetime, date
11
+ from psycopg2.extras import Json
7
12
 
8
13
  from ..errors import DatabaseError, ConnectionError
9
14
  from ..retry import retry
@@ -13,13 +18,13 @@ from ..types import JsonDict, Lookup
13
18
 
14
19
 
15
20
  class PostgreSQLAdapter:
16
- """PostgreSQL with full LINQ support, connection pooling"""
21
+ """PostgreSQL with full LINQ support, connection pooling, JSON/JSONB support"""
17
22
 
18
- def __init__(self):
23
+ def __init__(self, connection_string: Optional[str] = None):
19
24
  from ..utils import setup_logger
20
25
 
21
26
  self.logger = setup_logger(__name__)
22
- self.connection_string = os.getenv(
27
+ self.connection_string = connection_string or os.getenv(
23
28
  "POSTGRES_CONNECTION_STRING",
24
29
  os.getenv("POSTGRES_URL", ""),
25
30
  )
@@ -53,10 +58,14 @@ class PostgreSQLAdapter:
53
58
  if self._pool and conn:
54
59
  self._pool.putconn(conn)
55
60
 
61
+ # ---------------------------------------------------------------------
62
+ # TRANSACTIONS
63
+ # ---------------------------------------------------------------------
64
+
56
65
  def begin_transaction(self) -> Any:
57
66
  """Begin a transaction and return the connection handle."""
58
67
  conn = self._get_connection()
59
- conn.autocommit = False # Ensure transaction mode
68
+ conn.autocommit = False
60
69
  return conn
61
70
 
62
71
  def commit(self, tx: Any):
@@ -71,6 +80,87 @@ class PostgreSQLAdapter:
71
80
  tx.rollback()
72
81
  self._return_connection(tx)
73
82
 
83
+ # ---------------------------------------------------------------------
84
+ # JSON HELPERS
85
+ # ---------------------------------------------------------------------
86
+ def _json_safe(self, obj: Any):
87
+ """
88
+ Ensure JSON serialization never fails.
89
+ Used only for Json() wrapping.
90
+ """
91
+ if isinstance(obj, datetime):
92
+ return obj.isoformat()
93
+ if isinstance(obj, Decimal):
94
+ return float(obj)
95
+ if isinstance(obj, date):
96
+ return str(obj)
97
+ if isinstance(obj, dict):
98
+ return {k: self._json_safe(v) for k, v in obj.items()}
99
+ if isinstance(obj, list):
100
+ return [self._json_safe(v) for v in obj]
101
+ return obj
102
+
103
+ def _serialize_value(self, v: Any) -> Any:
104
+ """
105
+ Make all outgoing values safe for psycopg2.
106
+
107
+ Rules:
108
+ - dict -> Json()
109
+ - list -> leave as list (so TEXT[] works)
110
+ - datetime/date -> pass as native (psycopg2 handles it)
111
+ - Decimal -> convert to float
112
+ - everything else -> pass as-is
113
+ """
114
+
115
+ if v is None:
116
+ return None
117
+
118
+ # Dict -> JSON/JSONB
119
+ if isinstance(v, dict):
120
+ return Json(self._json_safe(v))
121
+
122
+ # List:
123
+ # DO NOT wrap in Json() automatically.
124
+ # If column is JSONB, Postgres will still accept Json(list).
125
+ # But for TEXT[] columns we must send Python list.
126
+ if isinstance(v, list):
127
+ return v
128
+
129
+ # Datetime / date
130
+ if isinstance(v, (datetime, date)):
131
+ return v # psycopg2 handles natively
132
+
133
+ # Decimal
134
+ if isinstance(v, Decimal):
135
+ return float(v)
136
+
137
+ return v
138
+
139
+ def _serialize_params(self, params: List[Any]) -> List[Any]:
140
+ return [self._serialize_value(p) for p in params]
141
+
142
+ def _deserialize_row(self, row: JsonDict) -> JsonDict:
143
+ """
144
+ Postgres JSON/JSONB often comes back as dict already depending on driver config.
145
+ If it comes as a string, try json.loads safely.
146
+ """
147
+ for k, v in list(row.items()):
148
+ if isinstance(v, str):
149
+ s = v.strip()
150
+ # quick cheap check to avoid parsing normal strings
151
+ if (s.startswith("{") and s.endswith("}")) or (
152
+ s.startswith("[") and s.endswith("]")
153
+ ):
154
+ try:
155
+ row[k] = json.loads(s)
156
+ except Exception:
157
+ pass
158
+ return row
159
+
160
+ # ---------------------------------------------------------------------
161
+ # INSERT
162
+ # ---------------------------------------------------------------------
163
+
74
164
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
75
165
  def insert(self, table: str, data: JsonDict, tx: Optional[Any] = None) -> JsonDict:
76
166
  table = validate_table_name(table)
@@ -90,7 +180,9 @@ class PostgreSQLAdapter:
90
180
  placeholders = ", ".join(["%s"] * len(data))
91
181
  query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
92
182
 
93
- cursor.execute(query, list(data.values()))
183
+ values = [self._serialize_value(v) for v in data.values()]
184
+ cursor.execute(query, values)
185
+
94
186
  result_row = cursor.fetchone()
95
187
  columns_list = [desc[0] for desc in cursor.description]
96
188
  result = dict(zip(columns_list, result_row))
@@ -99,7 +191,8 @@ class PostgreSQLAdapter:
99
191
  conn.commit()
100
192
 
101
193
  cursor.close()
102
- return result
194
+ return self._deserialize_row(result)
195
+
103
196
  except Exception as e:
104
197
  if own_conn:
105
198
  conn.rollback()
@@ -108,6 +201,10 @@ class PostgreSQLAdapter:
108
201
  if own_conn and conn:
109
202
  self._return_connection(conn)
110
203
 
204
+ # ---------------------------------------------------------------------
205
+ # SELECT
206
+ # ---------------------------------------------------------------------
207
+
111
208
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
112
209
  def select(
113
210
  self,
@@ -128,16 +225,20 @@ class PostgreSQLAdapter:
128
225
  cursor = conn.cursor()
129
226
 
130
227
  sql = f"SELECT * FROM {table}"
131
- params = []
228
+ params: List[Any] = []
132
229
 
133
230
  if query:
134
- where_parts = []
231
+ where_parts: List[str] = []
135
232
  for k, v in query.items():
136
233
  validate_column_name(k)
137
- if isinstance(v, (list, tuple)):
234
+
235
+ # IMPORTANT: None must be "IS NULL" not "= %s"
236
+ if v is None:
237
+ where_parts.append(f"{k} IS NULL")
238
+ elif isinstance(v, (list, tuple)):
138
239
  placeholders = ",".join(["%s"] * len(v))
139
240
  where_parts.append(f"{k} IN ({placeholders})")
140
- params.extend(v)
241
+ params.extend(list(v))
141
242
  else:
142
243
  where_parts.append(f"{k} = %s")
143
244
  params.append(v)
@@ -146,15 +247,15 @@ class PostgreSQLAdapter:
146
247
  sql += " WHERE " + " AND ".join(where_parts)
147
248
 
148
249
  if limit:
149
- sql += f" LIMIT %s"
250
+ sql += " LIMIT %s"
150
251
  params.append(limit)
151
252
  if offset:
152
- sql += f" OFFSET %s"
253
+ sql += " OFFSET %s"
153
254
  params.append(offset)
154
255
 
155
- cursor.execute(sql, params)
256
+ cursor.execute(sql, self._serialize_params(params))
156
257
  columns = [desc[0] for desc in cursor.description]
157
- results = [dict(zip(columns, row)) for row in cursor.fetchall()]
258
+ results = [self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()]
158
259
  cursor.close()
159
260
 
160
261
  return results
@@ -164,6 +265,10 @@ class PostgreSQLAdapter:
164
265
  if own_conn and conn:
165
266
  self._return_connection(conn)
166
267
 
268
+ # ---------------------------------------------------------------------
269
+ # SELECT PAGE
270
+ # ---------------------------------------------------------------------
271
+
167
272
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
168
273
  def select_page(
169
274
  self,
@@ -183,6 +288,10 @@ class PostgreSQLAdapter:
183
288
  next_token = str(offset + page_size) if has_more else None
184
289
  return results, next_token
185
290
 
291
+ # ---------------------------------------------------------------------
292
+ # UPDATE
293
+ # ---------------------------------------------------------------------
294
+
186
295
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
187
296
  def update(
188
297
  self,
@@ -205,14 +314,17 @@ class PostgreSQLAdapter:
205
314
  cursor = conn.cursor()
206
315
 
207
316
  set_clause = ", ".join([f"{k} = %s" for k in data.keys()])
208
- params = list(data.values())
317
+ params: List[Any] = [self._serialize_value(v) for v in data.values()]
209
318
 
210
319
  if isinstance(entity_id, dict):
211
- where_parts = []
320
+ where_parts: List[str] = []
212
321
  for k, v in entity_id.items():
213
322
  validate_column_name(k)
214
- where_parts.append(f"{k} = %s")
215
- params.append(v)
323
+ if v is None:
324
+ where_parts.append(f"{k} IS NULL")
325
+ else:
326
+ where_parts.append(f"{k} = %s")
327
+ params.append(self._serialize_value(v))
216
328
  where_clause = " AND ".join(where_parts)
217
329
  else:
218
330
  where_clause = "id = %s"
@@ -232,7 +344,7 @@ class PostgreSQLAdapter:
232
344
  conn.commit()
233
345
 
234
346
  cursor.close()
235
- return result
347
+ return self._deserialize_row(result)
236
348
  except Exception as e:
237
349
  if own_conn:
238
350
  conn.rollback()
@@ -241,6 +353,10 @@ class PostgreSQLAdapter:
241
353
  if own_conn and conn:
242
354
  self._return_connection(conn)
243
355
 
356
+ # ---------------------------------------------------------------------
357
+ # UPSERT
358
+ # ---------------------------------------------------------------------
359
+
244
360
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
245
361
  def upsert(self, table: str, data: JsonDict, tx: Optional[Any] = None) -> JsonDict:
246
362
  table = validate_table_name(table)
@@ -260,20 +376,37 @@ class PostgreSQLAdapter:
260
376
  placeholders = ", ".join(["%s"] * len(data))
261
377
 
262
378
  conflict_columns = ["id"] if "id" in data else list(data.keys())[:1]
263
- update_clause = ", ".join(
264
- [f"{k} = EXCLUDED.{k}" for k in data.keys() if k not in conflict_columns]
265
- )
379
+ update_fields = [k for k in data.keys() if k not in conflict_columns]
380
+
381
+ if update_fields:
382
+ update_clause = ", ".join([f"{k} = EXCLUDED.{k}" for k in update_fields])
383
+ on_conflict = f"DO UPDATE SET {update_clause}"
384
+ else:
385
+ on_conflict = "DO NOTHING"
266
386
 
267
387
  query = f"""
268
- INSERT INTO {table} ({columns})
388
+ INSERT INTO {table} ({columns})
269
389
  VALUES ({placeholders})
270
- ON CONFLICT ({', '.join(conflict_columns)})
271
- DO UPDATE SET {update_clause}
390
+ ON CONFLICT ({', '.join(conflict_columns)})
391
+ {on_conflict}
272
392
  RETURNING *
273
393
  """
274
394
 
275
- cursor.execute(query, list(data.values()))
395
+ values = [self._serialize_value(v) for v in data.values()]
396
+ cursor.execute(query, values)
397
+
276
398
  result_row = cursor.fetchone()
399
+ if not result_row:
400
+ # DO NOTHING case: fetch existing row (best effort)
401
+ # If conflict is on id, we can read it back.
402
+ if "id" in conflict_columns and "id" in data:
403
+ cursor.execute(f"SELECT * FROM {table} WHERE id = %s", [data["id"]])
404
+ result_row = cursor.fetchone()
405
+ if not result_row:
406
+ raise DatabaseError("Upsert did nothing and existing row not found")
407
+ else:
408
+ raise DatabaseError("Upsert did nothing and cannot determine existing row")
409
+
277
410
  columns_list = [desc[0] for desc in cursor.description]
278
411
  result = dict(zip(columns_list, result_row))
279
412
 
@@ -281,7 +414,7 @@ class PostgreSQLAdapter:
281
414
  conn.commit()
282
415
 
283
416
  cursor.close()
284
- return result
417
+ return self._deserialize_row(result)
285
418
  except Exception as e:
286
419
  if own_conn:
287
420
  conn.rollback()
@@ -290,6 +423,10 @@ class PostgreSQLAdapter:
290
423
  if own_conn and conn:
291
424
  self._return_connection(conn)
292
425
 
426
+ # ---------------------------------------------------------------------
427
+ # DELETE
428
+ # ---------------------------------------------------------------------
429
+
293
430
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
294
431
  def delete(
295
432
  self, table: str, entity_id: Union[Any, Lookup], tx: Optional[Any] = None
@@ -304,13 +441,16 @@ class PostgreSQLAdapter:
304
441
  try:
305
442
  cursor = conn.cursor()
306
443
 
307
- params = []
444
+ params: List[Any] = []
308
445
  if isinstance(entity_id, dict):
309
- where_parts = []
446
+ where_parts: List[str] = []
310
447
  for k, v in entity_id.items():
311
448
  validate_column_name(k)
312
- where_parts.append(f"{k} = %s")
313
- params.append(v)
449
+ if v is None:
450
+ where_parts.append(f"{k} IS NULL")
451
+ else:
452
+ where_parts.append(f"{k} = %s")
453
+ params.append(self._serialize_value(v))
314
454
  where_clause = " AND ".join(where_parts)
315
455
  else:
316
456
  where_clause = "id = %s"
@@ -330,7 +470,7 @@ class PostgreSQLAdapter:
330
470
  conn.commit()
331
471
 
332
472
  cursor.close()
333
- return result
473
+ return self._deserialize_row(result)
334
474
  except Exception as e:
335
475
  if own_conn:
336
476
  conn.rollback()
@@ -339,11 +479,17 @@ class PostgreSQLAdapter:
339
479
  if own_conn and conn:
340
480
  self._return_connection(conn)
341
481
 
482
+ # ---------------------------------------------------------------------
483
+ # LINQ QUERY
484
+ # ---------------------------------------------------------------------
485
+
342
486
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
343
487
  def query_linq(
344
488
  self, table: str, builder: QueryBuilder, tx: Optional[Any] = None
345
489
  ) -> Union[List[JsonDict], int]:
490
+
346
491
  table = validate_table_name(table)
492
+
347
493
  conn = tx
348
494
  own_conn = False
349
495
  if not conn:
@@ -353,59 +499,99 @@ class PostgreSQLAdapter:
353
499
  try:
354
500
  cursor = conn.cursor()
355
501
 
502
+ # ------------------------------------------------
503
+ # SELECT clause
504
+ # ------------------------------------------------
505
+
356
506
  if builder.count_only:
357
507
  sql = f"SELECT COUNT(*) FROM {table}"
358
- elif builder.select_fields:
359
- for f in builder.select_fields:
508
+
509
+ elif builder.selected_fields:
510
+ for f in builder.selected_fields:
360
511
  validate_column_name(f)
361
- fields = ", ".join(builder.select_fields)
362
- if builder.distinct:
512
+
513
+ fields = ", ".join(builder.selected_fields)
514
+
515
+ if builder.distinct_flag:
363
516
  sql = f"SELECT DISTINCT {fields} FROM {table}"
364
517
  else:
365
518
  sql = f"SELECT {fields} FROM {table}"
519
+
366
520
  else:
367
521
  sql = f"SELECT * FROM {table}"
368
522
 
369
- params = []
523
+ params: List[Any] = []
370
524
 
525
+ # ------------------------------------------------
371
526
  # WHERE
527
+ # ------------------------------------------------
528
+
372
529
  where_clause, where_params = builder.to_sql_where()
530
+
373
531
  if where_clause:
374
532
  sql += f" WHERE {where_clause}"
375
533
  params.extend(where_params)
376
534
 
535
+ # ------------------------------------------------
377
536
  # GROUP BY
537
+ # ------------------------------------------------
538
+
378
539
  if builder.group_by_fields:
540
+
379
541
  for f in builder.group_by_fields:
380
542
  validate_column_name(f)
543
+
381
544
  sql += f" GROUP BY {', '.join(builder.group_by_fields)}"
382
545
 
546
+ # ------------------------------------------------
383
547
  # ORDER BY
548
+ # ------------------------------------------------
549
+
384
550
  if builder.order_by_fields:
551
+
385
552
  order_parts = []
553
+
386
554
  for field, desc in builder.order_by_fields:
555
+
387
556
  validate_column_name(field)
557
+
388
558
  direction = "DESC" if desc else "ASC"
559
+
389
560
  order_parts.append(f"{field} {direction}")
561
+
390
562
  sql += f" ORDER BY {', '.join(order_parts)}"
391
563
 
392
- # LIMIT / OFFSET
393
- if builder.take_count:
394
- sql += f" LIMIT %s"
564
+ # ------------------------------------------------
565
+ # LIMIT
566
+ # ------------------------------------------------
567
+
568
+ if builder.take_count is not None:
569
+ sql += " LIMIT %s"
395
570
  params.append(builder.take_count)
396
571
 
572
+ # ------------------------------------------------
573
+ # OFFSET
574
+ # ------------------------------------------------
575
+
397
576
  if builder.skip_count:
398
- sql += f" OFFSET %s"
577
+ sql += " OFFSET %s"
399
578
  params.append(builder.skip_count)
400
579
 
401
- cursor.execute(sql, params)
580
+ # ------------------------------------------------
581
+ # EXECUTE
582
+ # ------------------------------------------------
583
+
584
+ cursor.execute(sql, self._serialize_params(params))
402
585
 
403
586
  if builder.count_only:
404
587
  result = cursor.fetchone()[0]
588
+
405
589
  else:
406
590
  columns = [desc[0] for desc in cursor.description]
407
- results = [dict(zip(columns, row)) for row in cursor.fetchall()]
408
- result = results
591
+
592
+ result = [
593
+ self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()
594
+ ]
409
595
 
410
596
  cursor.close()
411
597
 
@@ -413,20 +599,22 @@ class PostgreSQLAdapter:
413
599
  conn.commit()
414
600
 
415
601
  return result
602
+
416
603
  except Exception as e:
604
+
417
605
  if own_conn:
418
606
  conn.rollback()
607
+
419
608
  raise DatabaseError(f"LINQ query failed: {str(e)}")
609
+
420
610
  finally:
611
+
421
612
  if own_conn and conn:
422
613
  self._return_connection(conn)
423
614
 
424
- def __del__(self):
425
- if self._pool:
426
- try:
427
- self._pool.closeall()
428
- except:
429
- pass
615
+ # ---------------------------------------------------------------------
616
+ # EXECUTE RAW SQL
617
+ # ---------------------------------------------------------------------
430
618
 
431
619
  @retry(max_attempts=3, delay=1.0, exceptions=(DatabaseError,))
432
620
  def execute(
@@ -447,17 +635,17 @@ class PostgreSQLAdapter:
447
635
  cursor = None
448
636
  try:
449
637
  cursor = conn.cursor()
450
-
451
638
  self.logger.debug("Executing raw SQL: %s", sql)
452
- cursor.execute(sql, params or [])
453
639
 
454
- # Fetch results (if any) BEFORE commit (fine either way)
640
+ exec_params = self._serialize_params(params or [])
641
+ cursor.execute(sql, exec_params)
642
+
455
643
  if fetch_one:
456
644
  row = cursor.fetchone()
457
645
  result = None
458
646
  if row:
459
647
  columns = [desc[0] for desc in cursor.description]
460
- result = dict(zip(columns, row))
648
+ result = self._deserialize_row(dict(zip(columns, row)))
461
649
  if own_conn:
462
650
  conn.commit()
463
651
  return result
@@ -465,12 +653,11 @@ class PostgreSQLAdapter:
465
653
  if fetch:
466
654
  rows = cursor.fetchall()
467
655
  columns = [desc[0] for desc in cursor.description]
468
- results = [dict(zip(columns, r)) for r in rows]
656
+ results = [self._deserialize_row(dict(zip(columns, r))) for r in rows]
469
657
  if own_conn:
470
658
  conn.commit()
471
659
  return results
472
660
 
473
- # Non-fetch execution (DDL/DML)
474
661
  if own_conn:
475
662
  conn.commit()
476
663
  return None
@@ -492,42 +679,57 @@ class PostgreSQLAdapter:
492
679
  if own_conn and conn:
493
680
  self._return_connection(conn)
494
681
 
682
+ # ---------------------------------------------------------------------
683
+ # DISTRIBUTED LOCK
684
+ # ---------------------------------------------------------------------
685
+
495
686
  @contextmanager
496
- def distributed_lock(self, lock_name: str):
687
+ def distributed_lock(self, lock_name: str) -> Iterator[None]:
497
688
  """
498
- PostgreSQL advisory lock.
499
- Cluster-wide distributed lock.
500
-
501
- Safe across:
502
- - Multiple pods
503
- - Multiple containers
504
- - Multiple instances
689
+ PostgreSQL advisory lock (session scoped).
690
+ - Always unlock before returning the pooled connection.
691
+ - Never wrap exceptions raised by the user block.
505
692
  """
506
-
507
693
  conn = None
694
+ cursor = None
695
+ lock_id = int(hashlib.sha256(lock_name.encode()).hexdigest(), 16) % (2**63)
696
+
508
697
  try:
509
698
  conn = self._get_connection()
510
699
  cursor = conn.cursor()
511
700
 
512
- # Convert lock_name to 64-bit hash
513
- lock_id = int(hashlib.sha256(lock_name.encode()).hexdigest(), 16) % (2**63)
514
-
515
- cursor.execute("SELECT pg_advisory_lock(%s);", (lock_id,))
516
- self.logger.debug(f"Acquired distributed lock: {lock_name}")
517
-
518
- yield
519
-
520
- cursor.execute("SELECT pg_advisory_unlock(%s);", (lock_id,))
521
- self.logger.debug(f"Released distributed lock: {lock_name}")
522
-
523
- cursor.close()
524
- conn.commit()
701
+ # Acquire lock (DB op) — if this fails, it's a DatabaseError
702
+ try:
703
+ cursor.execute("SELECT pg_advisory_lock(%s);", (lock_id,))
704
+ self.logger.debug("Acquired distributed lock: %s", lock_name)
705
+ except Exception as e:
706
+ raise DatabaseError(f"Distributed lock acquire failed: {e}") from e
525
707
 
526
- except Exception as e:
527
- if conn:
528
- conn.rollback()
529
- raise DatabaseError(f"Distributed lock failed: {str(e)}")
708
+ try:
709
+ # User code runs here. If it raises, it MUST propagate unchanged.
710
+ yield
711
+ finally:
712
+ # Release lock (DB op). Always attempted.
713
+ try:
714
+ cursor.execute("SELECT pg_advisory_unlock(%s);", (lock_id,))
715
+ self.logger.debug("Released distributed lock: %s", lock_name)
716
+ except Exception as e:
717
+ # IMPORTANT: don't mask user exceptions.
718
+ # If unlock fails, raise DatabaseError only if user block didn't already fail.
719
+ # Easiest safe behavior: just log and continue.
720
+ self.logger.exception(
721
+ "Distributed lock release failed for %s: %s", lock_name, e
722
+ )
530
723
 
531
724
  finally:
725
+ if cursor:
726
+ try:
727
+ cursor.close()
728
+ except Exception:
729
+ pass
532
730
  if conn:
731
+ try:
732
+ conn.commit()
733
+ except Exception:
734
+ pass
533
735
  self._return_connection(conn)