altcodepro-polydb-python 2.3.15__tar.gz → 2.3.17__tar.gz

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 (83) hide show
  1. {altcodepro_polydb_python-2.3.15/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.3.17}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/AzureTableStorageAdapter.py +129 -24
  5. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/PostgreSQLAdapter.py +73 -3
  6. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/databaseFactory.py +51 -11
  7. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/retry.py +23 -0
  8. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/LICENSE +0 -0
  9. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/MANIFEST.in +0 -0
  10. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/README.md +0 -0
  11. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/requirements-aws.txt +0 -0
  12. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/requirements-azure.txt +0 -0
  13. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/requirements-dev.txt +0 -0
  14. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/requirements-gcp.txt +0 -0
  15. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/requirements-generic.txt +0 -0
  16. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/requirements.txt +0 -0
  17. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/setup.cfg +0 -0
  18. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/setup.py +0 -0
  19. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +0 -0
  20. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  21. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  22. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  23. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/PolyDB.py +0 -0
  24. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/__init__.py +0 -0
  25. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  26. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  27. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
  28. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  29. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  30. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/DynamoDBAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/EFSAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/MongoDBAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/S3Adapter.py +0 -0
  40. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/SQSAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  44. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/adapters/__init__.py +0 -0
  47. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/advanced_query.py +0 -0
  48. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/audit/AuditStorage.py +0 -0
  49. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/audit/__init__.py +0 -0
  50. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/audit/context.py +0 -0
  51. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/audit/manager.py +0 -0
  52. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/audit/models.py +0 -0
  53. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/base/NoSQLKVAdapter.py +0 -0
  54. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  55. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/base/QueueAdapter.py +0 -0
  56. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/base/SharedFilesAdapter.py +0 -0
  57. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/base/__init__.py +0 -0
  58. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/batch.py +0 -0
  59. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/cache.py +0 -0
  60. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/cloudDatabaseFactory.py +0 -0
  61. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/decorators.py +0 -0
  62. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/errors.py +0 -0
  63. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/json_safe.py +0 -0
  64. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/models.py +0 -0
  65. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/monitoring.py +0 -0
  66. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/multitenancy.py +0 -0
  67. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/py.typed +0 -0
  68. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/query.py +0 -0
  69. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/registry.py +0 -0
  70. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/schema.py +0 -0
  71. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/security.py +0 -0
  72. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/types.py +0 -0
  73. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/utils.py +0 -0
  74. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/src/polydb/validation.py +0 -0
  75. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_aws.py +0 -0
  76. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_azure.py +0 -0
  77. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_blockchain.py +0 -0
  78. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_cloud_factory.py +0 -0
  79. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_gcp.py +0 -0
  80. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_mongodb.py +0 -0
  81. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_multi_engine.py +0 -0
  82. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_postgresql.py +0 -0
  83. {altcodepro_polydb_python-2.3.15 → altcodepro_polydb_python-2.3.17}/tests/test_vercel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.3.15
3
+ Version: 2.3.17
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "altcodepro-polydb-python"
7
- version = "2.3.15"
7
+ version = "2.3.17"
8
8
  description = "Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.3.15
3
+ Version: 2.3.17
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -31,6 +31,13 @@ _BASE64_RE = re.compile(r"^[A-Za-z0-9+/]*={0,2}$")
31
31
  # ensures model isolation across the same table
32
32
  _MODEL_FIELD = "__polydb_model__"
33
33
 
34
+ # ─── NEW: HTTP transport tuning ──────────────────────────────────────────────
35
+ # Default Azure SDK has no read timeout. A sync call from an async event loop
36
+ # can park forever. These are overridable via env so ops can tune per env.
37
+ _CONNECT_TIMEOUT = float(os.getenv("AZURE_TABLE_CONNECT_TIMEOUT", "10"))
38
+ _READ_TIMEOUT = float(os.getenv("AZURE_TABLE_READ_TIMEOUT", "30"))
39
+ _RETRY_TOTAL = int(os.getenv("AZURE_TABLE_RETRY_TOTAL", "3"))
40
+
34
41
  logging.getLogger("azure").setLevel(logging.ERROR)
35
42
 
36
43
 
@@ -43,6 +50,15 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
43
50
  - Blob overflow for entities > 1MB
44
51
  - Model isolation using __polydb_model__
45
52
  - Always returns id (derived from RowKey if missing)
53
+
54
+ Performance / safety guarantees:
55
+ - One TableServiceClient per adapter instance.
56
+ - One TableClient cached per table for the adapter's lifetime.
57
+ - create_table_if_not_exists runs ONCE per table per adapter lifetime.
58
+ - Explicit connect/read timeouts on the HTTP transport so a slow or
59
+ mis-configured endpoint fails fast instead of hanging the caller.
60
+ - No mutation of instance state inside read/write paths (was the
61
+ previous `self._table_client = ...` race hazard).
46
62
  """
47
63
 
48
64
  AZURE_TABLE_MAX_SIZE = 60 * 1024 # 1MB
@@ -67,6 +83,16 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
67
83
  self._client: Any = None
68
84
  self._blob_service = None
69
85
  self._client_lock = threading.Lock()
86
+
87
+ # ─── NEW: caches ────────────────────────────────────────────────────
88
+ # _ensured_tables: table names for which create_table_if_not_exists
89
+ # has already succeeded on this adapter instance.
90
+ # _table_clients_cache: TableClient handles by table name.
91
+ # _cache_lock: protects both caches from concurrent get/put.
92
+ self._ensured_tables: set[str] = set()
93
+ self._table_clients_cache: Dict[str, Any] = {}
94
+ self._cache_lock = threading.Lock()
95
+
70
96
  self._initialize_client()
71
97
 
72
98
  def _initialize_client(self):
@@ -76,9 +102,22 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
76
102
 
77
103
  with self._client_lock:
78
104
  if not self._client:
79
- self._client = TableServiceClient.from_connection_string(self.connection_string)
105
+ # ─── CHANGED: explicit transport timeouts + retry budget ──
106
+ # connection_timeout / read_timeout are honoured by the
107
+ # azure-core RequestsTransport. retry_total caps the
108
+ # SDK's built-in retry policy so a hard failure surfaces
109
+ # in seconds, not minutes.
110
+ self._client = TableServiceClient.from_connection_string(
111
+ self.connection_string,
112
+ connection_timeout=_CONNECT_TIMEOUT,
113
+ read_timeout=_READ_TIMEOUT,
114
+ retry_total=_RETRY_TOTAL,
115
+ )
80
116
  self._blob_service = BlobServiceClient.from_connection_string(
81
- self.connection_string
117
+ self.connection_string,
118
+ connection_timeout=_CONNECT_TIMEOUT,
119
+ read_timeout=_READ_TIMEOUT,
120
+ retry_total=_RETRY_TOTAL,
82
121
  )
83
122
 
84
123
  try:
@@ -379,22 +418,50 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
379
418
 
380
419
  def _get_table_client(self, model: type):
381
420
  """
382
- Get table client + automatically create the table if it doesn't exist.
383
- This is the recommended pattern for Azure Table Storage.
421
+ Return a TableClient for the model, ensuring the table exists.
422
+
423
+ ─── CHANGED ────────────────────────────────────────────────────────
424
+ Both the table existence check AND the TableClient handle are now
425
+ cached per adapter instance. The previous version ran
426
+ create_table_if_not_exists on every read/write/query/delete — a
427
+ hot-loop seed of 100 records produced ~300 unnecessary network
428
+ round-trips. Now:
429
+
430
+ - First call for a table: 1 HTTP call (create_table_if_not_exists)
431
+ + 1 cheap local TableClient construction.
432
+ - All subsequent calls: O(1) dict lookup, no network.
384
433
  """
385
434
  table_name = self._get_table_name(model)
386
435
 
387
- try:
388
- # This is the key call - creates the table if missing
389
- self._client.create_table_if_not_exists(table_name)
390
- logger.info(f"✅ Azure Table ensured/created: {table_name}")
391
- except Exception as e:
392
- # TableAlreadyExists is normal and safe to ignore
393
- if "TableAlreadyExists" not in str(e) and "already exists" not in str(e).lower():
394
- logger.warning(f"Could not create table {table_name}: {e}")
436
+ # Fast path: already ensured + client cached.
437
+ cached = self._table_clients_cache.get(table_name)
438
+ if cached is not None and table_name in self._ensured_tables:
439
+ return cached
440
+
441
+ with self._cache_lock:
442
+ # Re-check inside the lock to avoid duplicate ensures under
443
+ # concurrent first-touch.
444
+ cached = self._table_clients_cache.get(table_name)
445
+ already_ensured = table_name in self._ensured_tables
446
+
447
+ if not already_ensured:
448
+ try:
449
+ self._client.create_table_if_not_exists(table_name)
450
+ logger.info(f"✅ Azure Table ensured/created: {table_name}")
451
+ except Exception as e:
452
+ # TableAlreadyExists is normal and safe to ignore
453
+ msg = str(e)
454
+ if "TableAlreadyExists" not in msg and "already exists" not in msg.lower():
455
+ logger.warning(f"Could not create table {table_name}: {e}")
456
+ # Mark as ensured regardless — either it exists now or we
457
+ # logged the failure; we won't retry on every op.
458
+ self._ensured_tables.add(table_name)
459
+
460
+ if cached is None:
461
+ cached = self._client.get_table_client(table_name=table_name)
462
+ self._table_clients_cache[table_name] = cached
395
463
 
396
- # Now return the client
397
- return self._client.get_table_client(table_name=table_name)
464
+ return cached
398
465
 
399
466
  def _restore_overflow_properties(self, entity_dict: JsonDict) -> JsonDict:
400
467
  """Detect and restore any large properties stored in Blob Storage.
@@ -456,7 +523,12 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
456
523
  @retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
457
524
  def _put_raw(self, model: type, pk: str, rk: str, data: JsonDict) -> JsonDict:
458
525
  try:
459
- self._table_client = self._get_table_client(model)
526
+ # ─── CHANGED: local variable, not self._table_client ────────────
527
+ # The old pattern `self._table_client = self._get_table_client(...)`
528
+ # was a race hazard: two concurrent writes on different models
529
+ # could swap each other's client mid-call. Local variable is
530
+ # safe and identical in cost since the client is now cached.
531
+ table_client = self._get_table_client(model)
460
532
  safe_pk = self._sanitize_pk_rk(pk)
461
533
  safe_rk = self._sanitize_pk_rk(rk)
462
534
  # Pack entity (encoded for Azure Table)
@@ -506,8 +578,7 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
506
578
  else:
507
579
  reference_entity[k] = v
508
580
 
509
- self._table_client = self._get_table_client(model)
510
- self._table_client.upsert_entity(reference_entity)
581
+ table_client.upsert_entity(reference_entity)
511
582
 
512
583
  restored = self._unpack_entity(entity)
513
584
  restored["id"] = safe_rk
@@ -525,9 +596,10 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
525
596
  try:
526
597
  safe_pk = self._sanitize_pk_rk(pk)
527
598
  safe_rk = self._sanitize_pk_rk(rk)
528
- self._table_client = self._get_table_client(model)
599
+ # ─── CHANGED: local variable ────────────────────────────────────
600
+ table_client = self._get_table_client(model)
529
601
 
530
- entity = self._table_client.get_entity(safe_pk, safe_rk)
602
+ entity = table_client.get_entity(safe_pk, safe_rk)
531
603
  entity_dict = dict(entity)
532
604
 
533
605
  # Model isolation check
@@ -553,7 +625,8 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
553
625
  self, model: type, filters: Dict[str, Any], limit: Optional[int]
554
626
  ) -> List[JsonDict]:
555
627
  try:
556
- self._table_client = self._get_table_client(model)
628
+ # ─── CHANGED: local variable ────────────────────────────────────
629
+ table_client = self._get_table_client(model)
557
630
 
558
631
  # Build query filter (your original logic kept unchanged)
559
632
  eff_filters = dict(filters or {})
@@ -590,7 +663,7 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
590
663
 
591
664
  query_filter = " and ".join(parts) if parts else None
592
665
 
593
- entities = self._table_client.query_entities(query_filter=query_filter)
666
+ entities = table_client.query_entities(query_filter=query_filter)
594
667
 
595
668
  results: List[JsonDict] = []
596
669
  count = 0
@@ -620,13 +693,14 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
620
693
  @retry(max_attempts=3, delay=1.0, exceptions=(NoSQLError,))
621
694
  def _delete_raw(self, model: type, pk: str, rk: str, etag: Optional[str]) -> JsonDict:
622
695
  try:
623
- self._table_client = self._get_table_client(model)
696
+ # ─── CHANGED: local variable ────────────────────────────────────
697
+ table_client = self._get_table_client(model)
624
698
  safe_pk = self._sanitize_pk_rk(pk)
625
699
  safe_rk = self._sanitize_pk_rk(rk)
626
700
 
627
701
  # read to check model + overflow
628
702
  try:
629
- entity = self._table_client.get_entity(safe_pk, safe_rk)
703
+ entity = table_client.get_entity(safe_pk, safe_rk)
630
704
  entity_dict = dict(entity)
631
705
 
632
706
  if entity_dict.get(_MODEL_FIELD) != model.__qualname__:
@@ -639,8 +713,39 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
639
713
  except Exception:
640
714
  pass
641
715
 
642
- self._table_client.delete_entity(safe_pk, safe_rk, etag=etag)
716
+ table_client.delete_entity(safe_pk, safe_rk, etag=etag)
643
717
  return {"deleted": True, "PartitionKey": safe_pk, "RowKey": safe_rk, "id": safe_rk}
644
718
 
645
719
  except Exception as e:
646
720
  raise NoSQLError(f"Azure Table delete failed: {str(e)}")
721
+
722
+ # -----------------------------
723
+ # Lifecycle
724
+ # -----------------------------
725
+ def close(self) -> None:
726
+ """Close cached clients. Safe to call multiple times.
727
+
728
+ ─── NEW ─────────────────────────────────────────────────────────────
729
+ Hook this into PolyDB shutdown so HTTP sessions don't leak on
730
+ application exit. Caches are cleared so a re-init starts clean.
731
+ """
732
+ with self._cache_lock:
733
+ for tc in self._table_clients_cache.values():
734
+ try:
735
+ tc.close()
736
+ except Exception:
737
+ pass
738
+ self._table_clients_cache.clear()
739
+ self._ensured_tables.clear()
740
+
741
+ with self._client_lock:
742
+ if self._client is not None:
743
+ try:
744
+ self._client.close()
745
+ except Exception:
746
+ pass
747
+ if self._blob_service is not None:
748
+ try:
749
+ self._blob_service.close()
750
+ except Exception:
751
+ pass
@@ -8,6 +8,9 @@ import hashlib
8
8
  from contextlib import contextmanager
9
9
  import json
10
10
  from datetime import datetime, date
11
+
12
+ import psycopg2.extensions
13
+
11
14
  from ..errors import DatabaseError, ConnectionError
12
15
  from ..retry import retry
13
16
  from ..utils import validate_table_name, validate_column_name
@@ -32,13 +35,54 @@ class PostgreSQLAdapter:
32
35
  self._lock = threading.Lock()
33
36
  self._initialize_pool()
34
37
 
38
+ # ---------------------------------------------------------------------
39
+ # CONNECTION HYGIENE HELPERS
40
+ # ---------------------------------------------------------------------
41
+
42
+ def _is_idle(self, conn) -> bool:
43
+ """True iff the connection is not inside a (possibly aborted) transaction."""
44
+ try:
45
+ return conn.info.transaction_status == psycopg2.extensions.TRANSACTION_STATUS_IDLE
46
+ except Exception:
47
+ return False
48
+
49
+ 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
+ if self._is_idle(conn):
53
+ return
54
+ try:
55
+ conn.rollback()
56
+ except Exception:
57
+ # Last resort: caller will surface a real error on next use.
58
+ pass
59
+
35
60
  def _ping_connection(self, conn) -> bool:
36
- """Test if connection is still alive"""
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
+ """
37
69
  try:
38
70
  with conn.cursor() as cur:
39
71
  cur.execute("SELECT 1")
72
+ # Clean up the auto-started transaction so the connection
73
+ # leaves this method in IDLE state.
74
+ try:
75
+ conn.rollback()
76
+ except Exception:
77
+ pass
40
78
  return True
41
79
  except Exception:
80
+ # Best-effort cleanup on a failed ping. The connection is
81
+ # almost certainly broken; the caller will close it.
82
+ try:
83
+ conn.rollback()
84
+ except Exception:
85
+ pass
42
86
  return False
43
87
 
44
88
  def _initialize_pool(self):
@@ -79,11 +123,15 @@ class PostgreSQLAdapter:
79
123
  try:
80
124
  conn = self._pool.getconn() # type: ignore
81
125
 
82
- # Critical: Validate connection for Azure transient issues
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.
83
129
  if not self._ping_connection(conn):
84
130
  self.logger.warning("Stale connection detected from pool, closing and retrying")
85
131
  self._pool.putconn(conn, close=True) # type: ignore
86
132
  conn = self._pool.getconn() # type: ignore # Get fresh connection
133
+ # New connection might still have been used before — make sure it's idle.
134
+ self._drain_transaction(conn)
87
135
 
88
136
  return conn
89
137
 
@@ -92,7 +140,13 @@ class PostgreSQLAdapter:
92
140
  raise ConnectionError(f"Could not obtain database connection: {e}") from e
93
141
 
94
142
  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
+ """
95
148
  if self._pool and conn:
149
+ self._drain_transaction(conn)
96
150
  self._pool.putconn(conn)
97
151
 
98
152
  # ---------------------------------------------------------------------
@@ -110,8 +164,16 @@ class PostgreSQLAdapter:
110
164
  self._initialize_pool()
111
165
 
112
166
  def begin_transaction(self) -> Any:
113
- """Begin a transaction and return the connection handle."""
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
+ """
114
175
  conn = self._get_connection()
176
+ self._drain_transaction(conn)
115
177
  conn.autocommit = False
116
178
  return conn
117
179
 
@@ -318,8 +380,16 @@ class PostgreSQLAdapter:
318
380
  results = [self._deserialize_row(dict(zip(columns, row))) for row in cursor.fetchall()]
319
381
  cursor.close()
320
382
 
383
+ if own_conn:
384
+ conn.commit()
385
+
321
386
  return results
322
387
  except Exception as e:
388
+ if own_conn:
389
+ try:
390
+ conn.rollback()
391
+ except Exception:
392
+ pass
323
393
  raise DatabaseError(f"Select failed: {str(e)}")
324
394
  finally:
325
395
  if own_conn and conn:
@@ -34,7 +34,7 @@ from .audit.manager import AuditManager
34
34
  from .audit.context import AuditContext
35
35
  from .query import Operator, QueryBuilder
36
36
  from .cloudDatabaseFactory import CloudDatabaseFactory
37
-
37
+ import re as _re
38
38
  logger = logging.getLogger(__name__)
39
39
 
40
40
  _DEFAULT_RETRY = retry(
@@ -43,7 +43,32 @@ _DEFAULT_RETRY = retry(
43
43
  reraise=True,
44
44
  )
45
45
 
46
+ _UNIQUE_VIOLATION_MARKERS = (
47
+ "23505", # Postgres SQLSTATE
48
+ "duplicate key value violates", # Postgres message
49
+ "unique constraint", # Postgres, generic
50
+ "UniqueViolation", # psycopg / SQLAlchemy class name
51
+ "Duplicate entry", # MySQL
52
+ "UNIQUE constraint failed", # SQLite
53
+ )
54
+ _UNIQUE_KEY_RE = _re.compile(r"Key \(([^)]+)\)=")
55
+
56
+
57
+ def _is_unique_violation(exc: BaseException) -> bool:
58
+ s = str(exc)
59
+ return any(m in s for m in _UNIQUE_VIOLATION_MARKERS)
60
+
46
61
 
62
+ def _parse_unique_violation_columns(exc: BaseException) -> list[str]:
63
+ """
64
+ Pull the conflicting column names out of a Postgres unique-violation error.
65
+ Postgres formats them as: Key (col1, col2)=(val1, val2) already exists.
66
+ Returns [] if the message doesn't carry that detail.
67
+ """
68
+ m = _UNIQUE_KEY_RE.search(str(exc))
69
+ if not m:
70
+ return []
71
+ return [c.strip() for c in m.group(1).split(",") if c.strip()]
47
72
  # ═══════════════════════════════════════════════════════════════════════════════
48
73
  # ENGINE CONFIG
49
74
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -359,9 +384,7 @@ class DatabaseFactory:
359
384
  logger.error("Audit recording failed: %s", exc)
360
385
 
361
386
  def _run(self, fn: Callable[[], Any]) -> Any:
362
- if not self._enable_retries:
363
- return fn()
364
- return _DEFAULT_RETRY(fn)()
387
+ return fn()
365
388
 
366
389
  def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
367
390
  if override and override.force_sql:
@@ -398,14 +421,32 @@ class DatabaseFactory:
398
421
  def _op() -> JsonDict:
399
422
  nonlocal after_plain, success, entity_id
400
423
  if self._is_sql(meta, engine_override):
401
- result = adapters.sql.insert(meta.table, data)
424
+ try:
425
+ result = adapters.sql.insert(meta.table, data)
426
+ except Exception as exc:
427
+ if not _is_unique_violation(exc):
428
+ raise
429
+ # Half-ran scenario / re-activation / replay. The record already
430
+ # exists with these unique-key columns. Preserve idempotent
431
+ # "create or update" semantics by routing to UPDATE keyed on the
432
+ # exact columns that conflicted (parsed from the Postgres error).
433
+ conflict_cols = _parse_unique_violation_columns(exc)
434
+ if not conflict_cols or not all(c in data for c in conflict_cols):
435
+ # Can't determine the conflict — re-raise so the caller sees it.
436
+ raise
437
+ where = {c: data[c] for c in conflict_cols}
438
+ logger.warning(
439
+ "insert %s hit unique violation on %s — falling through to update",
440
+ meta.table, conflict_cols,
441
+ )
442
+ # Drop the conflict columns from the UPDATE SET clause — they're
443
+ # already the matching key.
444
+ update_data = {k: v for k, v in data.items() if k not in conflict_cols}
445
+ result = adapters.sql.update(meta.table, where, update_data)
402
446
  else:
403
447
  result = adapters.nosql.put(
404
- (
405
- model
406
- if isinstance(model, type)
407
- else type(name, (), {"__polydb__": meta.__dict__})
408
- ),
448
+ model if isinstance(model, type)
449
+ else type(name, (), {"__polydb__": meta.__dict__}),
409
450
  data,
410
451
  )
411
452
  entity_id = result.get("id")
@@ -416,7 +457,6 @@ class DatabaseFactory:
416
457
  if self._enable_cache and self._cache:
417
458
  self._cache.invalidate(name)
418
459
  return after_plain
419
-
420
460
  try:
421
461
  monitor = (
422
462
  PerformanceMonitor(self.metrics, "create", name, None) if self.metrics else None
@@ -3,10 +3,33 @@
3
3
  Retry logic with exponential backoff and metrics hooks
4
4
  """
5
5
 
6
+ import functools
6
7
  import time
7
8
  import logging
8
9
  from functools import wraps
9
10
  from typing import Callable, Optional, Tuple, Type
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _NON_RETRYABLE_MARKERS = (
14
+ "23505", # Postgres unique_violation
15
+ "23503", # Postgres foreign_key_violation
16
+ "23502", # Postgres not_null_violation
17
+ "23514", # Postgres check_violation
18
+ "duplicate key value violates",
19
+ "unique constraint",
20
+ "UniqueViolation",
21
+ "Duplicate entry",
22
+ "UNIQUE constraint failed",
23
+ "PropertyValueTooLarge",
24
+ "ResourceNotFound",
25
+ "InvalidArgument",
26
+ "AuthenticationFailed",
27
+ )
28
+
29
+
30
+ def _is_non_retryable(exc: BaseException) -> bool:
31
+ s = str(exc)
32
+ return any(m in s for m in _NON_RETRYABLE_MARKERS)
10
33
 
11
34
 
12
35
  # Metrics hooks for enterprise monitoring