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.
@@ -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
- - Document id == pk (so querying {"id": ...} works)
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": <pk>} and raises DatabaseError on missing
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
- # IMPORTANT for tests: doc_id == pk == row["id"]
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": pk,
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
- # Ensure required identifiers exist for tests and convenience
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["id"] = pk
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
- return {"id": pk}
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", pk)
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
- # Ensure id present for tests
279
- row.setdefault("id", row.get("_pk") or d.id)
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": pk}
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": pk}
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("_pk") or d.id)
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