altcodepro-polydb-python 2.5.4__py3-none-any.whl → 2.5.7__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.
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/METADATA +69 -20
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/RECORD +28 -21
- polydb/__init__.py +1 -1
- polydb/adapters/AWSSecretsManagerAdapter.py +72 -0
- polydb/adapters/AzureKeyVaultAdapter.py +65 -0
- polydb/adapters/AzureQueueAdapter.py +82 -4
- polydb/adapters/DynamoDBAdapter.py +31 -16
- polydb/adapters/FirestoreAdapter.py +30 -13
- polydb/adapters/GCPPubSubAdapter.py +46 -0
- polydb/adapters/GCPSecretManagerAdapter.py +79 -0
- polydb/adapters/KafkaQueueAdapter.py +332 -0
- polydb/adapters/PostgreSQLAdapter.py +114 -6
- polydb/adapters/RabbitMQAdapter.py +465 -0
- polydb/adapters/SQSAdapter.py +60 -0
- polydb/adapters/VaultAdapter.py +75 -0
- polydb/adapters/VercelKVAdapter.py +38 -24
- polydb/adapters/VercelQueueAdapter.py +30 -2
- polydb/base/QueueAdapter.py +123 -1
- polydb/base/SecretsAdapter.py +30 -0
- polydb/cache.py +90 -0
- polydb/cloudDatabaseFactory.py +129 -15
- polydb/databaseFactory.py +262 -60
- polydb/errors.py +6 -0
- polydb/models.py +77 -0
- polydb/retry.py +7 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.4.dist-info → altcodepro_polydb_python-2.5.7.dist-info}/top_level.txt +0 -0
|
@@ -25,12 +25,21 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
25
25
|
Production-grade Firestore adapter with optional GCS overflow.
|
|
26
26
|
|
|
27
27
|
Goals (matches your tests)
|
|
28
|
-
-
|
|
28
|
+
- stored row keeps "id" == rk (the row key -- see NoSQLKVAdapter._get_pk_rk,
|
|
29
|
+
which derives rk from data.get(rk_field, ...) with rk_field defaulting
|
|
30
|
+
to "id"), so querying {"id": ...} matches the record's own id
|
|
29
31
|
- patch() merges (preserves existing fields)
|
|
30
|
-
- delete() returns {"id": <
|
|
32
|
+
- delete() returns {"id": <rk>} and raises DatabaseError on missing
|
|
31
33
|
- query_page() returns (rows, token) with stable pagination
|
|
32
34
|
- Emulator support via FIRESTORE_EMULATOR_HOST
|
|
33
35
|
|
|
36
|
+
NOTE: the Firestore *document id* is still `pk` alone (see _doc_id) --
|
|
37
|
+
this adapter does not compose pk+rk into the physical document key the
|
|
38
|
+
way VercelKVAdapter/DynamoDBAdapter do. That is a pre-existing, separate
|
|
39
|
+
design constraint (two rows sharing the same pk collide onto one
|
|
40
|
+
document) left as-is here since fixing it is a bigger structural change
|
|
41
|
+
this pass didn't verify against a live Firestore/emulator.
|
|
42
|
+
|
|
34
43
|
NOTE: google-cloud-firestore / google-cloud-storage are imported lazily
|
|
35
44
|
inside the methods that use them, so installing them is only required when
|
|
36
45
|
this adapter is actually used.
|
|
@@ -118,7 +127,7 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
118
127
|
return self._client.collection(self._collection_name(model))
|
|
119
128
|
|
|
120
129
|
def _doc_id(self, pk: str) -> str:
|
|
121
|
-
#
|
|
130
|
+
# Document id is the partition key alone (not composed with rk).
|
|
122
131
|
return str(pk)
|
|
123
132
|
|
|
124
133
|
def _blob_key(self, model: type, pk: str, rk: str, checksum: str) -> str:
|
|
@@ -148,7 +157,7 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
148
157
|
blob.upload_from_string(data_bytes)
|
|
149
158
|
|
|
150
159
|
ref: JsonDict = {
|
|
151
|
-
"id":
|
|
160
|
+
"id": rk,
|
|
152
161
|
"_pk": pk,
|
|
153
162
|
"_rk": rk,
|
|
154
163
|
"_overflow": True,
|
|
@@ -203,9 +212,15 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
203
212
|
collection = self._get_collection(model)
|
|
204
213
|
doc_id = self._doc_id(pk)
|
|
205
214
|
|
|
206
|
-
#
|
|
215
|
+
# "id" is the row key (see NoSQLKVAdapter._get_pk_rk, which derives
|
|
216
|
+
# rk from data.get(rk_field, ...) with rk_field defaulting to "id").
|
|
217
|
+
# It is already present in `payload` via dict(data) above whenever
|
|
218
|
+
# the caller supplied one -- only fall back to `rk`, never `pk`,
|
|
219
|
+
# which would silently collapse every row's "id" to its partition
|
|
220
|
+
# key and break id-addressed lookups (the same corruption
|
|
221
|
+
# previously present here and in VercelKVAdapter/DynamoDBAdapter).
|
|
207
222
|
payload: JsonDict = dict(data or {})
|
|
208
|
-
payload
|
|
223
|
+
payload.setdefault("id", rk)
|
|
209
224
|
payload["_pk"] = pk
|
|
210
225
|
payload["_rk"] = rk
|
|
211
226
|
|
|
@@ -215,7 +230,8 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
215
230
|
else:
|
|
216
231
|
collection.document(doc_id).set(payload)
|
|
217
232
|
|
|
218
|
-
|
|
233
|
+
# Return the full stored record, not just {"id": pk}.
|
|
234
|
+
return overflow_ref if overflow_ref is not None else payload
|
|
219
235
|
|
|
220
236
|
except Exception as e:
|
|
221
237
|
raise NoSQLError(f"Firestore put failed: {e}")
|
|
@@ -231,7 +247,7 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
231
247
|
return None
|
|
232
248
|
|
|
233
249
|
doc_data = snap.to_dict() or {}
|
|
234
|
-
doc_data.setdefault("id",
|
|
250
|
+
doc_data.setdefault("id", rk)
|
|
235
251
|
|
|
236
252
|
return self._resolve_overflow(doc_data)
|
|
237
253
|
|
|
@@ -275,8 +291,9 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
275
291
|
out: List[JsonDict] = []
|
|
276
292
|
for d in docs:
|
|
277
293
|
row = d.to_dict() or {}
|
|
278
|
-
#
|
|
279
|
-
|
|
294
|
+
# Fall back to the row key, not the partition key, when "id"
|
|
295
|
+
# is absent from the stored document (legacy/incomplete data).
|
|
296
|
+
row.setdefault("id", row.get("_rk") or d.id)
|
|
280
297
|
out.append(self._resolve_overflow(row))
|
|
281
298
|
return out
|
|
282
299
|
|
|
@@ -288,7 +305,7 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
288
305
|
"""
|
|
289
306
|
Test expectations:
|
|
290
307
|
- deleting nonexistent raises sqlite3.DatabaseError
|
|
291
|
-
- delete returns {"id":
|
|
308
|
+
- delete returns {"id": rk}
|
|
292
309
|
- deletes overflow blob if present
|
|
293
310
|
"""
|
|
294
311
|
try:
|
|
@@ -311,7 +328,7 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
311
328
|
pass
|
|
312
329
|
|
|
313
330
|
collection.document(doc_id).delete()
|
|
314
|
-
return {"id":
|
|
331
|
+
return {"id": rk}
|
|
315
332
|
|
|
316
333
|
except DatabaseError:
|
|
317
334
|
raise
|
|
@@ -355,7 +372,7 @@ class FirestoreAdapter(NoSQLKVAdapter):
|
|
|
355
372
|
rows: List[JsonDict] = []
|
|
356
373
|
for d in docs:
|
|
357
374
|
row = d.to_dict() or {}
|
|
358
|
-
row.setdefault("id", row.get("
|
|
375
|
+
row.setdefault("id", row.get("_rk") or d.id)
|
|
359
376
|
rows.append(self._resolve_overflow(row))
|
|
360
377
|
|
|
361
378
|
next_token = None
|
|
@@ -237,3 +237,49 @@ class GCPPubSubAdapter(QueueAdapter):
|
|
|
237
237
|
|
|
238
238
|
except Exception as e:
|
|
239
239
|
raise QueueError(f"Pub/Sub ack failed: {e}")
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------
|
|
242
|
+
# extend -- real GCP Pub/Sub ModifyAckDeadline. delay()/cancel() are
|
|
243
|
+
# NOT overridden: a published Pub/Sub message is deliverable
|
|
244
|
+
# immediately (no "don't make this visible until N seconds from
|
|
245
|
+
# now" primitive exists), so the base class's NotImplementedError
|
|
246
|
+
# is the honest answer for both.
|
|
247
|
+
# ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
# Pub/Sub's own real, documented maximum ack deadline. Google's API
|
|
250
|
+
# rejects anything above this with an invalid-argument error, so
|
|
251
|
+
# enforce it here rather than leaving callers to hit an opaque
|
|
252
|
+
# remote error.
|
|
253
|
+
MAX_ACK_DEADLINE_SECONDS = 600
|
|
254
|
+
|
|
255
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
256
|
+
def extend(
|
|
257
|
+
self, ack_id: str, queue_name: str = "default", *, visibility_timeout: int = 30
|
|
258
|
+
) -> bool:
|
|
259
|
+
"""Real Pub/Sub ModifyAckDeadline -- ack_id is the same ack_id
|
|
260
|
+
receive() returned for this message."""
|
|
261
|
+
if not ack_id:
|
|
262
|
+
raise QueueError("ack_id is required for Pub/Sub extend")
|
|
263
|
+
if visibility_timeout > self.MAX_ACK_DEADLINE_SECONDS:
|
|
264
|
+
raise QueueError(
|
|
265
|
+
f"Pub/Sub ack deadline cannot exceed {self.MAX_ACK_DEADLINE_SECONDS}s "
|
|
266
|
+
f"(GCP's own real maximum), got {visibility_timeout}"
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
if not self._subscriber:
|
|
270
|
+
raise ConnectionError("Pub/Sub subscriber not initialized")
|
|
271
|
+
|
|
272
|
+
_, subscription = self._resolve_names(queue_name)
|
|
273
|
+
sub_path = self._subscription_path(subscription)
|
|
274
|
+
|
|
275
|
+
self._subscriber.modify_ack_deadline(
|
|
276
|
+
request={
|
|
277
|
+
"subscription": sub_path,
|
|
278
|
+
"ack_ids": [ack_id],
|
|
279
|
+
"ack_deadline_seconds": visibility_timeout,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
except Exception as e:
|
|
285
|
+
raise QueueError(f"Pub/Sub extend failed: {e}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# src/polydb/adapters/GCPSecretManagerAdapter.py
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ..base.SecretsAdapter import SecretsAdapter
|
|
6
|
+
from ..errors import ConnectionError
|
|
7
|
+
from ..retry import retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GCPSecretManagerAdapter(SecretsAdapter):
|
|
11
|
+
def __init__(self, project_id: str = ""):
|
|
12
|
+
super().__init__()
|
|
13
|
+
|
|
14
|
+
self.project_id = project_id or os.getenv("GOOGLE_CLOUD_PROJECT")
|
|
15
|
+
if not self.project_id:
|
|
16
|
+
raise ConnectionError("GOOGLE_CLOUD_PROJECT is not configured")
|
|
17
|
+
|
|
18
|
+
self._client = None
|
|
19
|
+
self._initialize_client()
|
|
20
|
+
|
|
21
|
+
def _initialize_client(self) -> None:
|
|
22
|
+
from google.cloud import secretmanager
|
|
23
|
+
|
|
24
|
+
self._client = secretmanager.SecretManagerServiceClient()
|
|
25
|
+
|
|
26
|
+
def _secret_path(self, key: str) -> str:
|
|
27
|
+
return f"projects/{self.project_id}/secrets/{key}"
|
|
28
|
+
|
|
29
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(Exception,))
|
|
30
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
31
|
+
from google.api_core.exceptions import NotFound
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
resp = self._client.access_secret_version(
|
|
35
|
+
name=f"{self._secret_path(key)}/versions/latest"
|
|
36
|
+
)
|
|
37
|
+
except NotFound:
|
|
38
|
+
return None
|
|
39
|
+
return resp.payload.data.decode("utf-8")
|
|
40
|
+
|
|
41
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(Exception,))
|
|
42
|
+
def set_secret(self, key: str, value: str) -> None:
|
|
43
|
+
from google.api_core.exceptions import AlreadyExists
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
self._client.create_secret(
|
|
47
|
+
request={
|
|
48
|
+
"parent": f"projects/{self.project_id}",
|
|
49
|
+
"secret_id": key,
|
|
50
|
+
"secret": {"replication": {"automatic": {}}},
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
except AlreadyExists:
|
|
54
|
+
pass
|
|
55
|
+
self._client.add_secret_version(
|
|
56
|
+
request={
|
|
57
|
+
"parent": self._secret_path(key),
|
|
58
|
+
"payload": {"data": value.encode("utf-8")},
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(Exception,))
|
|
63
|
+
def delete_secret(self, key: str) -> bool:
|
|
64
|
+
from google.api_core.exceptions import NotFound
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
self._client.delete_secret(request={"name": self._secret_path(key)})
|
|
68
|
+
return True
|
|
69
|
+
except NotFound:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(Exception,))
|
|
73
|
+
def list_secrets(self, prefix: str = "") -> list[str]:
|
|
74
|
+
secrets = self._client.list_secrets(request={"parent": f"projects/{self.project_id}"})
|
|
75
|
+
return [
|
|
76
|
+
s.name.rsplit("/", 1)[-1]
|
|
77
|
+
for s in secrets
|
|
78
|
+
if s.name.rsplit("/", 1)[-1].startswith(prefix)
|
|
79
|
+
]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# src/polydb/adapters/KafkaQueueAdapter.py
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..base.QueueAdapter import QueueAdapter
|
|
9
|
+
from ..errors import ConnectionError, QueueError
|
|
10
|
+
from ..retry import retry
|
|
11
|
+
from ..json_safe import json_safe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KafkaQueueAdapter(QueueAdapter):
|
|
15
|
+
"""
|
|
16
|
+
Apache Kafka adapter using the synchronous `kafka-python` client, not
|
|
17
|
+
`aiokafka`. Every other adapter in this codebase (SQS, Azure Queue,
|
|
18
|
+
Pub/Sub, RabbitMQ, ...) is synchronous end to end -- pulling in an
|
|
19
|
+
asyncio-native client here would mean either spinning an event loop
|
|
20
|
+
per call or wrapping every call in asyncio.run(), both strictly worse
|
|
21
|
+
than using the client kafka-python already provides for exactly this
|
|
22
|
+
call shape.
|
|
23
|
+
|
|
24
|
+
queue_name IS the Kafka topic.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
bootstrap_servers: str = "",
|
|
30
|
+
group_id: str = "",
|
|
31
|
+
client_id: str = "",
|
|
32
|
+
security_protocol: str = "",
|
|
33
|
+
sasl_mechanism: str = "",
|
|
34
|
+
sasl_plain_username: str = "",
|
|
35
|
+
sasl_plain_password: str = "",
|
|
36
|
+
ssl_cafile: str = "",
|
|
37
|
+
auto_offset_reset: str = "earliest",
|
|
38
|
+
):
|
|
39
|
+
super().__init__()
|
|
40
|
+
|
|
41
|
+
servers = bootstrap_servers or os.getenv("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")
|
|
42
|
+
self.bootstrap_servers = [s.strip() for s in servers.split(",") if s.strip()]
|
|
43
|
+
|
|
44
|
+
# A random per-instance consumer group by default so that two
|
|
45
|
+
# independently-constructed adapters (e.g. two test runs, or two
|
|
46
|
+
# unrelated callers in the same process) don't silently share
|
|
47
|
+
# partition assignment / offsets with each other. Callers that
|
|
48
|
+
# actually want shared work-queue semantics across processes pass
|
|
49
|
+
# an explicit group_id (or set KAFKA_GROUP_ID).
|
|
50
|
+
self.group_id = (
|
|
51
|
+
group_id or os.getenv("KAFKA_GROUP_ID") or f"polydb-{uuid.uuid4().hex[:12]}"
|
|
52
|
+
)
|
|
53
|
+
self.client_id = client_id or os.getenv("KAFKA_CLIENT_ID", "polydb")
|
|
54
|
+
self.auto_offset_reset = auto_offset_reset
|
|
55
|
+
|
|
56
|
+
# Auth/TLS are all optional -- PLAINTEXT (no auth) is the default
|
|
57
|
+
# so local/dev brokers work with zero extra config, matching how
|
|
58
|
+
# e.g. SQSAdapter defaults endpoint_url to "" (real AWS) rather
|
|
59
|
+
# than requiring LocalStack settings to be supplied.
|
|
60
|
+
self.security_protocol = security_protocol or os.getenv(
|
|
61
|
+
"KAFKA_SECURITY_PROTOCOL", "PLAINTEXT"
|
|
62
|
+
)
|
|
63
|
+
self.sasl_mechanism = sasl_mechanism or os.getenv("KAFKA_SASL_MECHANISM") or None
|
|
64
|
+
self.sasl_plain_username = (
|
|
65
|
+
sasl_plain_username or os.getenv("KAFKA_SASL_USERNAME") or None
|
|
66
|
+
)
|
|
67
|
+
self.sasl_plain_password = (
|
|
68
|
+
sasl_plain_password or os.getenv("KAFKA_SASL_PASSWORD") or None
|
|
69
|
+
)
|
|
70
|
+
self.ssl_cafile = ssl_cafile or os.getenv("KAFKA_SSL_CAFILE") or None
|
|
71
|
+
|
|
72
|
+
self._producer: Any = None
|
|
73
|
+
self._consumers: Dict[str, Any] = {} # topic -> KafkaConsumer
|
|
74
|
+
|
|
75
|
+
# message_id -> (TopicPartition, offset_to_commit). Populated by
|
|
76
|
+
# receive(), consumed (and popped) by ack()/delete(). See the long
|
|
77
|
+
# comment on receive() for why committing only happens here.
|
|
78
|
+
self._pending: Dict[str, Any] = {}
|
|
79
|
+
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------
|
|
83
|
+
# Client initialization
|
|
84
|
+
# ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _client_kwargs(self) -> Dict[str, Any]:
|
|
87
|
+
kwargs: Dict[str, Any] = {
|
|
88
|
+
"bootstrap_servers": self.bootstrap_servers,
|
|
89
|
+
"security_protocol": self.security_protocol,
|
|
90
|
+
}
|
|
91
|
+
if self.sasl_mechanism:
|
|
92
|
+
kwargs["sasl_mechanism"] = self.sasl_mechanism
|
|
93
|
+
kwargs["sasl_plain_username"] = self.sasl_plain_username
|
|
94
|
+
kwargs["sasl_plain_password"] = self.sasl_plain_password
|
|
95
|
+
if self.ssl_cafile:
|
|
96
|
+
kwargs["ssl_cafile"] = self.ssl_cafile
|
|
97
|
+
return kwargs
|
|
98
|
+
|
|
99
|
+
def _get_producer(self):
|
|
100
|
+
from kafka import KafkaProducer
|
|
101
|
+
|
|
102
|
+
if self._producer is not None:
|
|
103
|
+
return self._producer
|
|
104
|
+
|
|
105
|
+
with self._lock:
|
|
106
|
+
if self._producer is not None:
|
|
107
|
+
return self._producer
|
|
108
|
+
try:
|
|
109
|
+
self._producer = KafkaProducer(
|
|
110
|
+
client_id=self.client_id,
|
|
111
|
+
# We JSON-encode to bytes ourselves (matches json_safe
|
|
112
|
+
# usage elsewhere in this codebase), so the serializer
|
|
113
|
+
# is a passthrough rather than kafka-python's own.
|
|
114
|
+
value_serializer=lambda v: v,
|
|
115
|
+
**self._client_kwargs(),
|
|
116
|
+
)
|
|
117
|
+
self.logger.info(
|
|
118
|
+
f"Initialized Kafka producer (bootstrap={self.bootstrap_servers})"
|
|
119
|
+
)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
raise ConnectionError(f"Kafka producer init failed: {e}")
|
|
122
|
+
return self._producer
|
|
123
|
+
|
|
124
|
+
def _get_consumer(self, topic: str):
|
|
125
|
+
if topic in self._consumers:
|
|
126
|
+
return self._consumers[topic]
|
|
127
|
+
|
|
128
|
+
from kafka import KafkaConsumer
|
|
129
|
+
|
|
130
|
+
with self._lock:
|
|
131
|
+
if topic in self._consumers:
|
|
132
|
+
return self._consumers[topic]
|
|
133
|
+
try:
|
|
134
|
+
consumer = KafkaConsumer(
|
|
135
|
+
topic,
|
|
136
|
+
group_id=self.group_id,
|
|
137
|
+
client_id=self.client_id,
|
|
138
|
+
# Manual commits only -- see receive()'s docstring for
|
|
139
|
+
# why offsets are committed exclusively from ack()/
|
|
140
|
+
# delete(), never automatically here.
|
|
141
|
+
enable_auto_commit=False,
|
|
142
|
+
auto_offset_reset=self.auto_offset_reset,
|
|
143
|
+
**self._client_kwargs(),
|
|
144
|
+
)
|
|
145
|
+
self.logger.info(f"Initialized Kafka consumer (topic={topic}, group={self.group_id})")
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise ConnectionError(f"Kafka consumer init failed: {e}")
|
|
148
|
+
self._consumers[topic] = consumer
|
|
149
|
+
return consumer
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------
|
|
152
|
+
# Queue operations
|
|
153
|
+
# ---------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
156
|
+
def send(self, message: Dict[str, Any], queue_name: str = "default") -> str:
|
|
157
|
+
"""Produce a message to `queue_name` (the Kafka topic)."""
|
|
158
|
+
try:
|
|
159
|
+
producer = self._get_producer()
|
|
160
|
+
body = (
|
|
161
|
+
json.dumps(message, default=json_safe).encode("utf-8")
|
|
162
|
+
if not isinstance(message, (bytes, bytearray))
|
|
163
|
+
else message
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
future = producer.send(queue_name, value=body)
|
|
167
|
+
# kafka-python's send() is async by default (it returns a
|
|
168
|
+
# FutureRecordMetadata immediately, before the broker has
|
|
169
|
+
# necessarily accepted the record). .get() blocks for that ack
|
|
170
|
+
# so send() returns only once the message is durably produced
|
|
171
|
+
# -- matching every other adapter's synchronous
|
|
172
|
+
# send()->message_id contract instead of firing-and-forgetting.
|
|
173
|
+
record = future.get(timeout=10)
|
|
174
|
+
return f"{record.partition}-{record.offset}"
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
raise QueueError(f"Kafka send failed: {e}")
|
|
178
|
+
|
|
179
|
+
@retry(max_attempts=3, delay=1.0, exceptions=(QueueError,))
|
|
180
|
+
def receive(self, queue_name: str = "default", max_messages: int = 1) -> List[Dict[str, Any]]:
|
|
181
|
+
"""
|
|
182
|
+
Poll up to `max_messages` from `queue_name`'s consumer group.
|
|
183
|
+
|
|
184
|
+
Deliberately does NOT commit offsets here (the consumer is created
|
|
185
|
+
with enable_auto_commit=False too). A message only becomes "done"
|
|
186
|
+
from Kafka's point of view once ack()/delete() commits its offset.
|
|
187
|
+
If the caller crashes after receive() but before ack(), nothing
|
|
188
|
+
was ever committed, so the next poll -- this process restarted, or
|
|
189
|
+
any other consumer sharing this group_id -- redelivers the message
|
|
190
|
+
from the same offset. That's the same at-least-once shape SQS's
|
|
191
|
+
visibility timeout, Azure's visibility timeout, and Pub/Sub's
|
|
192
|
+
unacked-redelivery already give this codebase's other queue
|
|
193
|
+
adapters. Auto-committing inside receive() would instead mark a
|
|
194
|
+
message "consumed" the instant it's handed out, which loses
|
|
195
|
+
redelivery on a crash mid-processing -- effectively at-most-once,
|
|
196
|
+
not the at-least-once contract the rest of this codebase relies on
|
|
197
|
+
(WorkerPool retries on a failed/never-acked message).
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
from kafka import TopicPartition
|
|
201
|
+
|
|
202
|
+
consumer = self._get_consumer(queue_name)
|
|
203
|
+
|
|
204
|
+
out: List[Dict[str, Any]] = []
|
|
205
|
+
# poll() returns whatever's ready in a single batch, which may
|
|
206
|
+
# be less than max_messages even when more exists -- loop
|
|
207
|
+
# (bounded) rather than assuming one poll() satisfies the ask.
|
|
208
|
+
attempts = 0
|
|
209
|
+
while len(out) < max_messages and attempts < 5:
|
|
210
|
+
attempts += 1
|
|
211
|
+
remaining = max_messages - len(out)
|
|
212
|
+
batches = consumer.poll(timeout_ms=1000, max_records=remaining)
|
|
213
|
+
if not batches:
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
for tp, records in batches.items():
|
|
217
|
+
for record in records:
|
|
218
|
+
message_id = f"{record.partition}-{record.offset}"
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
body = json.loads(record.value.decode("utf-8"))
|
|
222
|
+
except Exception:
|
|
223
|
+
body = record.value.decode("utf-8", errors="replace")
|
|
224
|
+
|
|
225
|
+
out.append(
|
|
226
|
+
{
|
|
227
|
+
"id": message_id,
|
|
228
|
+
"receipt_handle": message_id,
|
|
229
|
+
"body": body,
|
|
230
|
+
"topic": tp.topic,
|
|
231
|
+
"partition": tp.partition,
|
|
232
|
+
"offset": record.offset,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Kafka commit semantics: the committed offset is
|
|
237
|
+
# "the next record to read", so we store
|
|
238
|
+
# offset + 1, not offset itself.
|
|
239
|
+
self._pending[message_id] = (
|
|
240
|
+
TopicPartition(tp.topic, tp.partition),
|
|
241
|
+
record.offset + 1,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if len(out) >= max_messages:
|
|
245
|
+
break
|
|
246
|
+
if len(out) >= max_messages:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
raise QueueError(f"Kafka receive failed: {e}")
|
|
253
|
+
|
|
254
|
+
def _commit(self, message_id: str, queue_name: str) -> bool:
|
|
255
|
+
from kafka import OffsetAndMetadata
|
|
256
|
+
|
|
257
|
+
pending = self._pending.pop(message_id, None)
|
|
258
|
+
if pending is None:
|
|
259
|
+
# Unknown or already-committed id -- treat as a no-op success.
|
|
260
|
+
# Matches VercelQueueAdapter/BlockchainQueueAdapter's existing
|
|
261
|
+
# convention of a redundant ack being harmless rather than an
|
|
262
|
+
# error.
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
tp, next_offset = pending
|
|
266
|
+
consumer = self._consumers.get(queue_name)
|
|
267
|
+
if consumer is None:
|
|
268
|
+
raise QueueError(
|
|
269
|
+
f"No active Kafka consumer for topic '{queue_name}' to commit offset against"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
consumer.commit({tp: OffsetAndMetadata(next_offset, None)})
|
|
274
|
+
return True
|
|
275
|
+
except Exception as e:
|
|
276
|
+
raise QueueError(f"Kafka offset commit failed: {e}")
|
|
277
|
+
|
|
278
|
+
def delete(self, message_id: str, queue_name: str = "default", pop_receipt: str = "") -> bool:
|
|
279
|
+
"""
|
|
280
|
+
Delete == commit the offset. Kafka has no notion of deleting a
|
|
281
|
+
single record independent of consumer offsets, so -- matching the
|
|
282
|
+
majority convention among this codebase's other adapters (SQS,
|
|
283
|
+
Pub/Sub: ack is delete) -- delete() and ack() do the same thing.
|
|
284
|
+
"""
|
|
285
|
+
return self._commit(message_id, queue_name)
|
|
286
|
+
|
|
287
|
+
def ack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
288
|
+
"""Explicit ACK: commits the consumed offset for `ack_id`."""
|
|
289
|
+
if not ack_id:
|
|
290
|
+
raise QueueError("ack_id is required for Kafka ack")
|
|
291
|
+
return self._commit(ack_id, queue_name)
|
|
292
|
+
|
|
293
|
+
def nack(self, ack_id: str, queue_name: str = "default") -> bool:
|
|
294
|
+
"""
|
|
295
|
+
Documented no-op, not a design gap: receive()'s own docstring
|
|
296
|
+
already establishes that this adapter deliberately never commits
|
|
297
|
+
an offset until ack()/delete() does. That means a message is
|
|
298
|
+
already effectively "nacked" -- redeliverable to this (or any
|
|
299
|
+
other) consumer sharing group_id -- the instant it's received
|
|
300
|
+
and not yet acked; there is no separate broker-side "put it back"
|
|
301
|
+
call the way AMQP's basic_nack is, because nothing was ever
|
|
302
|
+
marked done in the first place.
|
|
303
|
+
|
|
304
|
+
This only pops the pending entry (mirroring _commit's own
|
|
305
|
+
"unknown/already-handled id is a harmless no-op" convention, and
|
|
306
|
+
returning the same True-if-there-was-something-to-act-on /
|
|
307
|
+
False-if-not shape _commit does) so a caller that explicitly
|
|
308
|
+
nacks doesn't also get to ack() the same id afterward -- it
|
|
309
|
+
exists for API-shape consistency with the other adapters'
|
|
310
|
+
nack(), not because Kafka needs an explicit call here to achieve
|
|
311
|
+
the redelivery.
|
|
312
|
+
"""
|
|
313
|
+
return self._pending.pop(ack_id, None) is not None
|
|
314
|
+
|
|
315
|
+
def close(self) -> None:
|
|
316
|
+
"""
|
|
317
|
+
Flush the producer and close every consumer. Not part of
|
|
318
|
+
QueueAdapter's abstract contract (none of the other adapters need
|
|
319
|
+
it -- boto3/Azure SDK/Pub/Sub clients don't hold a persistent
|
|
320
|
+
local socket the way a Kafka producer/consumer does), but good
|
|
321
|
+
hygiene for a long-lived adapter instance to call explicitly.
|
|
322
|
+
"""
|
|
323
|
+
if self._producer is not None:
|
|
324
|
+
try:
|
|
325
|
+
self._producer.close()
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
for consumer in self._consumers.values():
|
|
329
|
+
try:
|
|
330
|
+
consumer.close()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|