altcodepro-polydb-python 2.3.27__tar.gz → 2.3.29__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 (93) hide show
  1. {altcodepro_polydb_python-2.3.27/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.3.29}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +1 -0
  5. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/AzureQueueAdapter.py +90 -3
  6. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/MongoDBAdapter.py +3 -1
  7. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/databaseFactory.py +20 -17
  8. altcodepro_polydb_python-2.3.29/tests/test_azure_queue_receipt_handle.py +185 -0
  9. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/LICENSE +0 -0
  10. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/MANIFEST.in +0 -0
  11. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/README.md +0 -0
  12. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/requirements-aws.txt +0 -0
  13. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/requirements-azure.txt +0 -0
  14. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/requirements-dev.txt +0 -0
  15. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/requirements-gcp.txt +0 -0
  16. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/requirements-generic.txt +0 -0
  17. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/requirements.txt +0 -0
  18. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/setup.cfg +0 -0
  19. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/setup.py +0 -0
  20. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  21. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  22. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  23. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/PolyDB.py +0 -0
  24. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/__init__.py +0 -0
  25. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  26. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  27. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/AzureTableStorageAdapter.py +0 -0
  28. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  29. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  30. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/DynamoDBAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/EFSAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/PostgreSQLAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/S3Adapter.py +0 -0
  40. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/SQSAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  44. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/adapters/__init__.py +0 -0
  47. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/advanced_query.py +0 -0
  48. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/audit/AuditStorage.py +0 -0
  49. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/audit/__init__.py +0 -0
  50. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/audit/context.py +0 -0
  51. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/audit/manager.py +0 -0
  52. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/audit/models.py +0 -0
  53. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/base/NoSQLKVAdapter.py +0 -0
  54. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  55. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/base/QueueAdapter.py +0 -0
  56. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/base/SharedFilesAdapter.py +0 -0
  57. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/base/__init__.py +0 -0
  58. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/batch.py +0 -0
  59. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/cache.py +0 -0
  60. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/cloudDatabaseFactory.py +0 -0
  61. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/decorators.py +0 -0
  62. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/errors.py +0 -0
  63. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/json_safe.py +0 -0
  64. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/models.py +0 -0
  65. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/monitoring.py +0 -0
  66. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/multitenancy.py +0 -0
  67. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/observability/__init__.py +0 -0
  68. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/observability/logging.py +0 -0
  69. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/py.typed +0 -0
  70. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/query.py +0 -0
  71. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/registry.py +0 -0
  72. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/retry.py +0 -0
  73. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/schema.py +0 -0
  74. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/security.py +0 -0
  75. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/services/__init__.py +0 -0
  76. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/services/compliance_service.py +0 -0
  77. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/services/security_service.py +0 -0
  78. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/types.py +0 -0
  79. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/utils.py +0 -0
  80. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/src/polydb/validation.py +0 -0
  81. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_atomic_decrement.py +0 -0
  82. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_aws.py +0 -0
  83. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_azure.py +0 -0
  84. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_azure_table_empty_filter.py +0 -0
  85. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_azure_table_unpack_entity.py +0 -0
  86. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_blockchain.py +0 -0
  87. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_cloud_factory.py +0 -0
  88. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_gcp.py +0 -0
  89. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_mongodb.py +0 -0
  90. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_multi_engine.py +0 -0
  91. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_observability.py +0 -0
  92. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/tests/test_postgresql.py +0 -0
  93. {altcodepro_polydb_python-2.3.27 → altcodepro_polydb_python-2.3.29}/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.27
3
+ Version: 2.3.29
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.27"
7
+ version = "2.3.29"
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.27
3
+ Version: 2.3.29
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
@@ -78,6 +78,7 @@ src/polydb/services/security_service.py
78
78
  tests/test_atomic_decrement.py
79
79
  tests/test_aws.py
80
80
  tests/test_azure.py
81
+ tests/test_azure_queue_receipt_handle.py
81
82
  tests/test_azure_table_empty_filter.py
82
83
  tests/test_azure_table_unpack_entity.py
83
84
  tests/test_blockchain.py
@@ -46,6 +46,20 @@ class AzureQueueAdapter(QueueAdapter):
46
46
  name = re.sub(r"-+", "-", name) # collapse multiple dashes
47
47
  return name.strip("-")
48
48
 
49
+ @staticmethod
50
+ def _encode_receipt(message_id: str, pop_receipt: str) -> str:
51
+ # Azure needs BOTH message_id and pop_receipt to delete/update a
52
+ # message, but the generic queue contract (WorkerPool, SQSAdapter)
53
+ # only ever carries a single opaque `receipt_handle` string end to
54
+ # end. Pack both into one string here so ack()/nack() can recover
55
+ # them without any caller needing to know Azure's two-part scheme.
56
+ return f"{message_id}|{pop_receipt}"
57
+
58
+ @staticmethod
59
+ def _decode_receipt(receipt_handle: str) -> "tuple[str, str]":
60
+ message_id, _, pop_receipt = (receipt_handle or "").partition("|")
61
+ return message_id, pop_receipt
62
+
49
63
  def _initialize_client(self) -> None:
50
64
  """Initialize Azure Queue client"""
51
65
  from azure.storage.queue import QueueServiceClient
@@ -96,15 +110,38 @@ class AzureQueueAdapter(QueueAdapter):
96
110
  except Exception as e:
97
111
  raise QueueError(f"Azure Queue send failed: {e}")
98
112
 
113
+ # How long a delivered message stays invisible to other consumers before
114
+ # Azure hands it out again. The SDK's own default is 30s — far shorter
115
+ # than a real task (an LLM generation call alone can run 30-120s+), so
116
+ # a still-in-flight message was becoming visible again and getting
117
+ # picked up by a second worker mid-processing: two concurrent
118
+ # executions of the same task, one of which stomps/duplicates the
119
+ # other's result. 300s covers realistic worst-case processing; ack()
120
+ # deletes the message immediately on completion regardless; a genuinely
121
+ # crashed worker still recovers the message after this timeout.
122
+ DEFAULT_VISIBILITY_TIMEOUT = 300
123
+
99
124
  @retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
100
- def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict[str, Any]]:
125
+ def receive(
126
+ self,
127
+ queue_name: str = "default",
128
+ max_messages: int = 1,
129
+ visibility_timeout: Optional[int] = None,
130
+ ) -> List[Dict[str, Any]]:
101
131
  """Receive messages"""
102
132
  try:
103
133
 
104
134
  queue_name = self._normalize_queue_name(queue_name)
105
135
  queue_client = self._get_queue(queue_name)
106
136
 
107
- messages = queue_client.receive_messages(max_messages=max_messages)
137
+ messages = queue_client.receive_messages(
138
+ max_messages=max_messages,
139
+ visibility_timeout=(
140
+ visibility_timeout
141
+ if visibility_timeout is not None
142
+ else self.DEFAULT_VISIBILITY_TIMEOUT
143
+ ),
144
+ )
108
145
 
109
146
  results = []
110
147
 
@@ -114,6 +151,19 @@ class AzureQueueAdapter(QueueAdapter):
114
151
  {
115
152
  "id": msg.id,
116
153
  "pop_receipt": msg.pop_receipt,
154
+ # WorkerPool (and the generic UDL queue contract)
155
+ # reads this key to ack/nack. Without it, ack() is
156
+ # always skipped (falsy receipt_handle), the message
157
+ # is never deleted, and it silently reappears once
158
+ # Azure's visibility timeout elapses — replaying the
159
+ # same task forever even after it already ran.
160
+ "receipt_handle": self._encode_receipt(msg.id, msg.pop_receipt),
161
+ # How many times Azure has handed this same message
162
+ # out (1 on first delivery). Lets a consumer (e.g.
163
+ # WorkerPool) detect a poison message that keeps
164
+ # failing to ack/process and dead-letter it instead
165
+ # of retrying forever.
166
+ "dequeue_count": msg.dequeue_count,
117
167
  "body": payload,
118
168
  }
119
169
  )
@@ -156,8 +206,14 @@ class AzureQueueAdapter(QueueAdapter):
156
206
  Preferred usage:
157
207
  ack(pop_receipt=..., message_id=...)
158
208
 
159
- If message_id is not provided, this will fail safely.
209
+ Generic callers (WorkerPool, storage_router.queue_ack) only pass a
210
+ single `receipt_handle` positionally and never supply message_id —
211
+ in that case `pop_receipt` here is actually the combined value
212
+ produced by `receive()` (`"<message_id>|<pop_receipt>"`), so decode
213
+ it rather than failing.
160
214
  """
215
+ if not message_id:
216
+ message_id, pop_receipt = self._decode_receipt(pop_receipt)
161
217
  if not message_id:
162
218
  raise QueueError("AzureQueueAdapter.ack requires message_id")
163
219
  queue_name = self._normalize_queue_name(queue_name)
@@ -166,3 +222,34 @@ class AzureQueueAdapter(QueueAdapter):
166
222
  queue_name=queue_name,
167
223
  pop_receipt=pop_receipt,
168
224
  )
225
+
226
+ def nack(
227
+ self,
228
+ queue_name: str,
229
+ ack_id: str,
230
+ *,
231
+ delay: Optional[int] = None,
232
+ ) -> bool:
233
+ """
234
+ Make a message visible again (optionally after `delay` seconds)
235
+ without deleting it, instead of leaving it to reappear only once
236
+ Azure's default visibility timeout elapses.
237
+
238
+ `ack_id` is the combined `"<message_id>|<pop_receipt>"` receipt
239
+ handle produced by `receive()`. Matches the positional order used
240
+ by storage_router.queue_nack: nack(queue_name, ack_id, delay=...).
241
+ """
242
+ message_id, pop_receipt = self._decode_receipt(ack_id)
243
+ if not message_id:
244
+ raise QueueError("AzureQueueAdapter.nack requires a receipt_handle from receive()")
245
+ try:
246
+ queue_name = self._normalize_queue_name(queue_name)
247
+ queue_client = self._get_queue(queue_name)
248
+ queue_client.update_message(
249
+ message_id,
250
+ pop_receipt=pop_receipt,
251
+ visibility_timeout=delay or 0,
252
+ )
253
+ return True
254
+ except Exception as e:
255
+ raise QueueError(f"Azure Queue nack failed: {e}")
@@ -50,7 +50,9 @@ class MongoDBAdapter(NoSQLKVAdapter):
50
50
  self.mongo_uri,
51
51
  maxPoolSize=int(os.getenv("MONGODB_MAX_POOL_SIZE", "10")),
52
52
  minPoolSize=int(os.getenv("MONGODB_MIN_POOL_SIZE", "1")),
53
- serverSelectionTimeoutMS=5000,
53
+ serverSelectionTimeoutMS=int(
54
+ os.getenv("MONGODB_SERVER_SELECTION_TIMEOUT_MS", "5000")
55
+ ),
54
56
  )
55
57
 
56
58
  self._client.server_info()
@@ -109,8 +109,8 @@ class EngineOverride:
109
109
 
110
110
  @dataclass
111
111
  class _ResolvedAdapters:
112
- sql: PostgreSQLAdapter
113
- nosql: NoSQLKVAdapter
112
+ sql: Optional[PostgreSQLAdapter]
113
+ nosql: Optional[NoSQLKVAdapter]
114
114
  engine_name: str
115
115
 
116
116
 
@@ -233,35 +233,38 @@ class DatabaseFactory:
233
233
  def _resolve_adapters(
234
234
  self, model_name: str, storage: str, override: Optional[EngineOverride] = None
235
235
  ) -> _ResolvedAdapters:
236
+ # Only construct the adapter this call actually needs. Every caller
237
+ # (create/read/update/upsert/delete/query*) branches on `storage` and
238
+ # touches exactly one of .sql/.nosql — building both unconditionally
239
+ # meant a purely-SQL deployment with no reachable NoSQL backend (or
240
+ # vice versa) failed on every single call, not just ones that needed
241
+ # the missing engine.
242
+ def _build(engine: "EngineConfig") -> _ResolvedAdapters:
243
+ return _ResolvedAdapters(
244
+ sql=engine.sql() if storage == "sql" else None,
245
+ nosql=engine.nosql() if storage == "nosql" else None,
246
+ engine_name=engine.name,
247
+ )
248
+
236
249
  if override:
237
250
  engine = self._engine_by_name.get(override.engine_name)
238
251
  if engine is None:
239
252
  raise AdapterConfigurationError(
240
253
  f"Unknown engine '{override.engine_name}'. Available: {list(self._engine_by_name)}"
241
254
  )
242
- return _ResolvedAdapters(
243
- sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
244
- )
255
+ return _build(engine)
245
256
 
246
257
  for engine in self._engines:
247
258
  if storage == "sql" and engine.sql_models and model_name in engine.sql_models:
248
- return _ResolvedAdapters(
249
- sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
250
- )
259
+ return _build(engine)
251
260
  if storage == "nosql" and engine.nosql_models and model_name in engine.nosql_models:
252
- return _ResolvedAdapters(
253
- sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
254
- )
261
+ return _build(engine)
255
262
 
256
263
  for engine in self._engines:
257
264
  if storage == "sql" and engine.is_default_sql:
258
- return _ResolvedAdapters(
259
- sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
260
- )
265
+ return _build(engine)
261
266
  if storage == "nosql" and engine.is_default_nosql:
262
- return _ResolvedAdapters(
263
- sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
264
- )
267
+ return _build(engine)
265
268
 
266
269
  raise AdapterConfigurationError(f"No engine for model='{model_name}' storage='{storage}'")
267
270
 
@@ -0,0 +1,185 @@
1
+ """
2
+ tests/test_azure_queue_receipt_handle.py
3
+ =========================================
4
+ Regression coverage for the receipt_handle contract on AzureQueueAdapter.
5
+
6
+ Bug this guards against: `receive()` used to return only `id` and
7
+ `pop_receipt`, never the `receipt_handle` key that WorkerPool (and every
8
+ other adapter's ack contract) reads. That made
9
+ `WorkerPool._process_message`'s `msg.get("receipt_handle")` always `None`,
10
+ so the ack branch was silently skipped, the message was never deleted, and
11
+ Azure redelivered it once its visibility timeout elapsed — replaying the
12
+ same task forever even after it had already run to completion.
13
+
14
+ These tests mock the Azure SDK entirely (no live Azurite needed) and only
15
+ exercise the adapter's own encode/decode + ack/nack wiring.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ from unittest.mock import MagicMock, patch
23
+
24
+ import pytest
25
+
26
+ # test_atomic_decrement.py stubs sys.modules["azure"] et al. with bare,
27
+ # path-less ModuleType objects (guarded by `if _mod not in sys.modules`) so
28
+ # it can run without cloud SDKs installed. If it collects before this file
29
+ # (alphabetically it does), that stub wins the module cache for the rest of
30
+ # the process, and the adapter's lazy `from azure.core.exceptions import
31
+ # ...` imports then fail with "'azure' is not a package" -- even though the
32
+ # real azure SDK is installed here. Drop any such stubs so this file always
33
+ # imports the genuine package fresh.
34
+ for _name in list(sys.modules):
35
+ if _name == "azure" or _name.startswith("azure."):
36
+ del sys.modules[_name]
37
+
38
+ from polydb.adapters.AzureQueueAdapter import AzureQueueAdapter
39
+
40
+
41
+ def _make_adapter() -> AzureQueueAdapter:
42
+ # Bypass the real Azure SDK entirely -- when the full suite runs
43
+ # together, other providers' tests leave azure.storage.queue in a state
44
+ # where importing QueueServiceClient from it fails (a pre-existing
45
+ # cross-test import-cache issue, unrelated to this adapter). Skipping
46
+ # `_initialize_client` sidesteps that import altogether; these tests
47
+ # only care about the adapter's own encode/decode + ack/nack wiring.
48
+ with patch.object(AzureQueueAdapter, "_initialize_client", lambda self: None):
49
+ adapter = AzureQueueAdapter(connection_string="UseDevelopmentStorage=true")
50
+ adapter._client = MagicMock()
51
+ return adapter
52
+
53
+
54
+ def _mock_queue_client(adapter: AzureQueueAdapter, queue_name: str = "default") -> MagicMock:
55
+ client = MagicMock()
56
+ adapter._queues[adapter._normalize_queue_name(queue_name)] = client
57
+ return client
58
+
59
+
60
+ class TestReceiveReturnsReceiptHandle:
61
+ def test_receive_includes_receipt_handle_key(self):
62
+ adapter = _make_adapter()
63
+ client = _mock_queue_client(adapter)
64
+
65
+ raw_msg = MagicMock()
66
+ raw_msg.id = "msg-123"
67
+ raw_msg.pop_receipt = "pop-abc"
68
+ raw_msg.content = json.dumps({"hello": "world"})
69
+ client.receive_messages.return_value = [raw_msg]
70
+
71
+ [received] = adapter.receive(queue_name="default", max_messages=1)
72
+
73
+ assert received["id"] == "msg-123"
74
+ assert received["pop_receipt"] == "pop-abc"
75
+ assert "receipt_handle" in received
76
+ assert received["receipt_handle"] # truthy -- WorkerPool checks `if receipt_handle`
77
+
78
+ def test_receive_surfaces_dequeue_count(self):
79
+ """WorkerPool's poison-message guard thresholds on this to
80
+ dead-letter a message the queue keeps redelivering."""
81
+ adapter = _make_adapter()
82
+ client = _mock_queue_client(adapter)
83
+
84
+ raw_msg = MagicMock()
85
+ raw_msg.id = "msg-123"
86
+ raw_msg.pop_receipt = "pop-abc"
87
+ raw_msg.content = json.dumps({"hello": "world"})
88
+ raw_msg.dequeue_count = 7
89
+ client.receive_messages.return_value = [raw_msg]
90
+
91
+ [received] = adapter.receive(queue_name="default", max_messages=1)
92
+
93
+ assert received["dequeue_count"] == 7
94
+
95
+ def test_receipt_handle_round_trips_through_decode(self):
96
+ message_id, pop_receipt = AzureQueueAdapter._decode_receipt(
97
+ AzureQueueAdapter._encode_receipt("msg-123", "pop-abc")
98
+ )
99
+ assert message_id == "msg-123"
100
+ assert pop_receipt == "pop-abc"
101
+
102
+ def test_receive_defaults_to_a_long_visibility_timeout(self):
103
+ """The Azure SDK's own default (30s) is shorter than a real task
104
+ can take (an LLM call alone can run 30-120s+), so a still-in-flight
105
+ message became visible again and got picked up by a second worker
106
+ mid-processing -- two concurrent executions of the same task. This
107
+ guards against that default silently regressing back to 30s (or
108
+ being dropped entirely, which is the SDK's fallback)."""
109
+ adapter = _make_adapter()
110
+ client = _mock_queue_client(adapter)
111
+ client.receive_messages.return_value = []
112
+
113
+ adapter.receive(queue_name="default", max_messages=1)
114
+
115
+ _, kwargs = client.receive_messages.call_args
116
+ assert kwargs["visibility_timeout"] == AzureQueueAdapter.DEFAULT_VISIBILITY_TIMEOUT
117
+ assert kwargs["visibility_timeout"] >= 300
118
+
119
+ def test_receive_honours_an_explicit_visibility_timeout(self):
120
+ adapter = _make_adapter()
121
+ client = _mock_queue_client(adapter)
122
+ client.receive_messages.return_value = []
123
+
124
+ adapter.receive(queue_name="default", max_messages=1, visibility_timeout=45)
125
+
126
+ _, kwargs = client.receive_messages.call_args
127
+ assert kwargs["visibility_timeout"] == 45
128
+
129
+
130
+ class TestAckAcceptsSingleReceiptHandle:
131
+ def test_ack_with_generic_single_arg_deletes_using_decoded_parts(self):
132
+ """Mirrors how WorkerPool/storage_router actually call ack():
133
+ queue.ack(receipt_handle, queue_name) -- a single positional value,
134
+ never a separate message_id.
135
+ """
136
+ adapter = _make_adapter()
137
+ client = _mock_queue_client(adapter)
138
+
139
+ receipt_handle = AzureQueueAdapter._encode_receipt("msg-123", "pop-abc")
140
+ result = adapter.ack(receipt_handle, "default")
141
+
142
+ assert result is True
143
+ client.delete_message.assert_called_once_with("msg-123", "pop-abc")
144
+
145
+ def test_ack_still_supports_explicit_message_id(self):
146
+ adapter = _make_adapter()
147
+ client = _mock_queue_client(adapter)
148
+
149
+ result = adapter.ack("pop-abc", "default", message_id="msg-123")
150
+
151
+ assert result is True
152
+ client.delete_message.assert_called_once_with("msg-123", "pop-abc")
153
+
154
+ def test_ack_raises_if_receipt_handle_has_no_message_id(self):
155
+ adapter = _make_adapter()
156
+ _mock_queue_client(adapter)
157
+
158
+ with pytest.raises(Exception):
159
+ adapter.ack("", "default")
160
+
161
+
162
+ class TestNack:
163
+ def test_nack_updates_visibility_without_deleting(self):
164
+ adapter = _make_adapter()
165
+ client = _mock_queue_client(adapter)
166
+
167
+ receipt_handle = AzureQueueAdapter._encode_receipt("msg-123", "pop-abc")
168
+ result = adapter.nack("default", receipt_handle, delay=30)
169
+
170
+ assert result is True
171
+ client.update_message.assert_called_once_with(
172
+ "msg-123", pop_receipt="pop-abc", visibility_timeout=30
173
+ )
174
+ client.delete_message.assert_not_called()
175
+
176
+ def test_nack_defaults_delay_to_zero(self):
177
+ adapter = _make_adapter()
178
+ client = _mock_queue_client(adapter)
179
+
180
+ receipt_handle = AzureQueueAdapter._encode_receipt("msg-123", "pop-abc")
181
+ adapter.nack("default", receipt_handle)
182
+
183
+ client.update_message.assert_called_once_with(
184
+ "msg-123", pop_receipt="pop-abc", visibility_timeout=0
185
+ )