vercel-celery-bundle 0.7.1__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.
@@ -0,0 +1,1199 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ClassVar, cast
4
+ from vercel.internal._vendor.typing_extensions import override
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import threading
10
+ import time
11
+ from collections.abc import Iterator, Mapping
12
+ from dataclasses import dataclass
13
+ from functools import wraps
14
+ from urllib.parse import urlparse
15
+ from weakref import WeakKeyDictionary
16
+
17
+ from kombu.exceptions import ChannelError
18
+ from kombu.transport import resolve_transport, virtual
19
+ from kombu.transport.virtual.base import Empty
20
+ from kombu.utils import json as kombu_json
21
+
22
+ import vercel.queue as vqs
23
+ import vercel.queue.sync as vqs_sync
24
+ from celery import Celery, _state as celery_state
25
+
26
+ from .version import __version__
27
+
28
+ DEFAULT_CONSUMER_GROUP = "celery"
29
+ DEFAULT_REQUEUE_DELAY_SECONDS = 0
30
+ DEFAULT_PUSH_RETRY_DELAY_SECONDS = 1
31
+ # On Vercel, bouncing a push delivery with RetryAfter is expensive: redelivery
32
+ # is paced by the server and can take up to the full remaining visibility
33
+ # deadline. Prefer waiting in-request for the worker to become ready over
34
+ # bouncing, up to this budget.
35
+ DEFAULT_PUSH_HANDOFF_WAIT_SECONDS = 30.0
36
+ _PUSH_CHANNEL_WAIT_SECONDS = 5.0
37
+ _PUSH_WAIT_POLL_INTERVAL_SECONDS = 0.05
38
+ _PUSH_SETTLE_POLL_INTERVAL_SECONDS = 0.01
39
+ _EMBEDDED_WORKER_STARTUP_WAIT_SECONDS = 1.0
40
+ _QUEUE_LOGGER_NAME = "vercel.integrations.celery"
41
+ _DEBUG_ENV = "VERCEL_CELERY_DEBUG"
42
+ _DEBUG_LOGGER_NAMES = (
43
+ _QUEUE_LOGGER_NAME,
44
+ "celery",
45
+ "celery.app",
46
+ "celery.bootsteps",
47
+ "celery.worker",
48
+ "kombu",
49
+ "kombu.connection",
50
+ )
51
+ TransportClass = type[Any]
52
+ CLIENT_TRANSPORT_OPTIONS = (
53
+ "token",
54
+ "region",
55
+ "base_url",
56
+ "deployment",
57
+ "timeout",
58
+ "headers",
59
+ )
60
+ PUBLISH_TRANSPORT_OPTIONS = (
61
+ "retention",
62
+ "delay",
63
+ "use_task_id_as_idempotency_key",
64
+ )
65
+ LEASE_TRANSPORT_OPTIONS = (
66
+ "requeue_delay_seconds",
67
+ "push_retry_delay_seconds",
68
+ "push_handoff_wait_seconds",
69
+ "lease_duration",
70
+ )
71
+ CONSUMER_TRANSPORT_OPTIONS = ("consumer_group",)
72
+ QUEUE_TRANSPORT_OPTIONS = ("queue_name_prefix",)
73
+ # Celery apps can be short-lived in tests and app factories, so registration
74
+ # idempotency is keyed weakly by app object rather than by id(app). The values
75
+ # record only VQS-facing queue identity; when the Celery app is collected, its
76
+ # idempotency state disappears with it.
77
+ _registered_app_queues: WeakKeyDictionary[Celery, set[tuple[str, str]]] = WeakKeyDictionary()
78
+ _registered_queue_subscriptions: WeakKeyDictionary[Celery, dict[tuple[str, str], str]] = (
79
+ WeakKeyDictionary()
80
+ )
81
+ _registered_callbacks: WeakKeyDictionary[Celery, dict[tuple[str, str], Any]] = WeakKeyDictionary()
82
+
83
+
84
+ @dataclass
85
+ class _EmbeddedWorker:
86
+ app: Celery
87
+ worker: Any
88
+ thread: threading.Thread
89
+
90
+
91
+ _embedded_workers: WeakKeyDictionary[Celery, _EmbeddedWorker] = WeakKeyDictionary()
92
+ _embedded_workers_lock = threading.RLock()
93
+
94
+ # Push deliveries enter through VQS subscriber callbacks, not Kombu polling. We
95
+ # keep live push channels here so a callback can hand the leased VQS message to
96
+ # whichever Kombu channel currently has a consumer and prefetch capacity.
97
+ _push_channels: list[PushChannel | AutoChannel] = []
98
+ _push_channels_lock = threading.RLock()
99
+
100
+
101
+ @dataclass
102
+ class _FinalizeHookState:
103
+ installed: bool = False
104
+ register_queues: bool = False
105
+ default_broker_set_by_installer: bool = False
106
+ connection_transport_options_hook_installed: bool = False
107
+
108
+
109
+ _finalize_hook_state = _FinalizeHookState()
110
+
111
+
112
+ def _set_default_broker_set_by_installer(*, value: bool) -> None:
113
+ _finalize_hook_state.default_broker_set_by_installer = value
114
+
115
+
116
+ def debug_log(event: str, **fields: Any) -> None:
117
+ if not _queue_debug_enabled():
118
+ return
119
+ payload = {
120
+ "event": event,
121
+ **{name: value for name, value in fields.items() if value is not None},
122
+ }
123
+ logging.getLogger(_QUEUE_LOGGER_NAME).info(
124
+ json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
125
+ )
126
+
127
+
128
+ def _queue_debug_enabled() -> bool:
129
+ return os.environ.get(_DEBUG_ENV) in {"1", "true"}
130
+
131
+
132
+ def _configure_celery_debug_logging() -> None:
133
+ if not _queue_debug_enabled():
134
+ return
135
+ for logger_name in _DEBUG_LOGGER_NAMES:
136
+ logging.getLogger(logger_name).setLevel(logging.DEBUG)
137
+
138
+
139
+ class _KombuJSONDecoder(json.JSONDecoder):
140
+ def __init__(self) -> None:
141
+ super().__init__(object_hook=kombu_json.object_hook)
142
+
143
+
144
+ class _KombuMessageTransport(vqs.RawJsonTransport[dict[str, Any]]):
145
+ def __init__(self) -> None:
146
+ super().__init__(
147
+ json_encoder=kombu_json.JSONEncoder,
148
+ json_decoder=_KombuJSONDecoder,
149
+ )
150
+
151
+
152
+ @dataclass
153
+ class _TrackedDelivery:
154
+ message: vqs.Message[dict[str, Any]]
155
+ lease_renewal: vqs.LeaseRenewal
156
+ queue_client: vqs_sync.QueueClient
157
+
158
+
159
+ def is_vercel_runtime() -> bool:
160
+ try:
161
+ value = os.environ["VERCEL"]
162
+ except KeyError:
163
+ return False
164
+ return value.strip().casefold() in {"1", "yes", "on", "true"}
165
+
166
+
167
+ class _BaseChannel(virtual.Channel):
168
+ """Kombu virtual channel backed by Vercel Queue leases."""
169
+
170
+ do_restore = False
171
+ supports_fanout = False
172
+ from_transport_options: ClassVar[tuple[str, ...]] = ( # ty: ignore [invalid-attribute-override]
173
+ *CLIENT_TRANSPORT_OPTIONS,
174
+ *PUBLISH_TRANSPORT_OPTIONS,
175
+ *LEASE_TRANSPORT_OPTIONS,
176
+ *CONSUMER_TRANSPORT_OPTIONS,
177
+ *QUEUE_TRANSPORT_OPTIONS,
178
+ )
179
+
180
+ token: str | None = None
181
+ region: str | None = None
182
+ base_url: str | None = None
183
+ deployment: vqs.DeploymentOption = vqs.CURRENT_DEPLOYMENT
184
+ timeout: vqs.Duration | None = 10.0
185
+ requeue_delay_seconds: int = DEFAULT_REQUEUE_DELAY_SECONDS
186
+ push_retry_delay_seconds: int = DEFAULT_PUSH_RETRY_DELAY_SECONDS
187
+ push_handoff_wait_seconds: float = DEFAULT_PUSH_HANDOFF_WAIT_SECONDS
188
+ lease_duration: vqs.Duration | None = None
189
+ retention: vqs.Duration | None = None
190
+ delay: vqs.Duration | None = None
191
+ headers: Mapping[str, str] | None = None
192
+ use_task_id_as_idempotency_key: bool = False
193
+ consumer_group: str = DEFAULT_CONSUMER_GROUP
194
+ queue_name_prefix: str = ""
195
+
196
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
197
+ super().__init__(*args, **kwargs)
198
+ self.consumer_group = str(vqs.sanitize_name(self.consumer_group))
199
+ if self.queue_name_prefix is None:
200
+ self.queue_name_prefix = ""
201
+ else:
202
+ self.queue_name_prefix = str(self.queue_name_prefix)
203
+ self._queue_client = vqs_sync.QueueClient(
204
+ token=self.token,
205
+ region=self.region,
206
+ base_url=self.base_url,
207
+ deployment=self.deployment,
208
+ headers=self.headers,
209
+ timeout=self.timeout,
210
+ )
211
+ self._message_transport = _KombuMessageTransport()
212
+ self._messages_by_tag: dict[str, _TrackedDelivery] = {}
213
+ self._consumed_queues_by_tag: dict[str, str] = {}
214
+ self._poll_queue_offset = 0
215
+ # VQS push callbacks can enter this channel concurrently on SDK
216
+ # threads. Serialize handoff into Kombu so the prefetch gate, tracked
217
+ # lease state, and private QoS bookkeeping move together.
218
+ self._push_handoff_lock = threading.RLock()
219
+
220
+ def _topic(self, queue: str) -> vqs.Topic[dict[str, Any]]:
221
+ return vqs.Topic[dict[str, Any]](
222
+ vqs.sanitize_name(f"{self.queue_name_prefix}{queue}"),
223
+ transport=self._message_transport,
224
+ )
225
+
226
+ def _put(self, queue: str, message: dict[str, Any], **kwargs: Any) -> None:
227
+ # Kombu gives transports its already-normalized message envelope. Store
228
+ # that envelope directly in VQS so receive paths can hand it back to
229
+ # Kombu without reconstructing Celery protocol fields.
230
+ self._queue_client.send(
231
+ self._topic(queue),
232
+ message,
233
+ idempotency_key=self._idempotency_key(message),
234
+ retention=self.retention,
235
+ delay=self.delay,
236
+ headers=self.headers,
237
+ )
238
+
239
+ @override
240
+ def basic_ack(self, delivery_tag: str, multiple: bool = False) -> None:
241
+ # Kombu ACKs by delivery tag; VQS ACKs by receipt handle. The receive
242
+ # path records the full VQS message under Kombu's tag so this method can
243
+ # translate the ACK back into a VQS lease deletion.
244
+ # Celery/Kombu consumers do not use multiple ACKs with virtual
245
+ # transports. If one does, Kombu clears the local QoS bookkeeping, but
246
+ # VQS cannot expand multiple=True to earlier leases and can only
247
+ # acknowledge the explicit tag's lease here.
248
+ tracked = self._messages_by_tag.get(delivery_tag)
249
+ try:
250
+ self._ack_tracked_delivery(delivery_tag)
251
+ finally:
252
+ if tracked is not None and self._qos is not None:
253
+ super().basic_ack(delivery_tag, multiple=multiple)
254
+
255
+ @override
256
+ def basic_reject(self, delivery_tag: str, requeue: bool = False) -> None:
257
+ # Celery's reject(requeue=True) maps to making the VQS lease visible
258
+ # again after requeue_delay_seconds. reject(requeue=False) is a terminal
259
+ # disposition, so ACK the VQS lease instead of changing visibility.
260
+ message = self._messages_by_tag.get(delivery_tag)
261
+ if message is not None:
262
+ if requeue:
263
+ tracked_message = self._stop_tracking_delivery(delivery_tag)
264
+ if tracked_message is not None:
265
+ message.queue_client.retry_after(tracked_message, self.requeue_delay_seconds)
266
+ else:
267
+ try:
268
+ message.queue_client.acknowledge(message.message)
269
+ finally:
270
+ self._stop_tracking_delivery(delivery_tag)
271
+ if message is not None and self._qos is not None:
272
+ # The VQS follow-up above already handled requeue semantics. Tell
273
+ # Kombu only to remove local QoS bookkeeping for this delivery.
274
+ super().basic_reject(delivery_tag, requeue=False)
275
+
276
+ @override
277
+ def basic_get(self, queue: str, no_ack: bool = False, **kwargs: Any) -> Any:
278
+ message = super().basic_get(queue, no_ack=no_ack, **kwargs)
279
+ if message is not None and no_ack:
280
+ # VQS has no server-side no_ack mode. Once Kombu has accepted the
281
+ # delivery locally, delete the VQS lease immediately without
282
+ # touching Kombu QoS bookkeeping; basic_get(no_ack=True) never added
283
+ # this tag to QoS._delivered.
284
+ self._ack_tracked_delivery(message.delivery_tag)
285
+ return message
286
+
287
+ @override
288
+ def basic_consume(
289
+ self,
290
+ queue: str,
291
+ no_ack: bool,
292
+ callback: Any,
293
+ consumer_tag: str,
294
+ **kwargs: Any,
295
+ ) -> None:
296
+ def wrapped_callback(message: Any) -> Any:
297
+ try:
298
+ result = callback(message)
299
+ except Exception:
300
+ if no_ack:
301
+ self._release_failed_no_ack_delivery(message.delivery_tag)
302
+ raise
303
+ if no_ack:
304
+ # Mirror basic_get(no_ack=True): successful local delivery is
305
+ # the ACK point because Celery will not call basic_ack later.
306
+ self._ack_tracked_delivery(message.delivery_tag)
307
+ return result
308
+
309
+ super().basic_consume(
310
+ queue,
311
+ no_ack=no_ack,
312
+ callback=wrapped_callback,
313
+ consumer_tag=consumer_tag,
314
+ **kwargs,
315
+ )
316
+ self._consumed_queues_by_tag[consumer_tag] = queue
317
+
318
+ @override
319
+ def basic_cancel(self, consumer_tag: str) -> None:
320
+ try:
321
+ super().basic_cancel(consumer_tag)
322
+ finally:
323
+ self._consumed_queues_by_tag.pop(consumer_tag, None)
324
+
325
+ def _consumes_queue(self, queue: str) -> bool:
326
+ return queue in self._consumed_queues_by_tag.values()
327
+
328
+ def _release_failed_no_ack_delivery(self, delivery_tag: str) -> None:
329
+ tracked = self._messages_by_tag.get(delivery_tag)
330
+ if tracked is None:
331
+ return
332
+ tracked_message = self._stop_tracking_delivery(delivery_tag)
333
+ if tracked_message is None:
334
+ return
335
+ tracked.queue_client.retry_after(tracked_message, self.requeue_delay_seconds)
336
+
337
+ def _restore(self, message: Any) -> None:
338
+ return None
339
+
340
+ def _restore_at_beginning(self, message: Any) -> None:
341
+ return None
342
+
343
+ def _purge(self, queue: str) -> int:
344
+ raise ChannelError("Vercel Queue Service does not support queue purge")
345
+
346
+ def _track_message(
347
+ self,
348
+ message: vqs.Message[dict[str, Any]],
349
+ *,
350
+ queue_client: vqs_sync.QueueClient | None = None,
351
+ ) -> dict[str, Any]:
352
+ queue_client = queue_client or self._queue_client
353
+ payload = dict(message.payload)
354
+ properties = payload.get("properties")
355
+ properties = {} if not isinstance(properties, dict) else dict(properties)
356
+ payload["properties"] = properties
357
+
358
+ delivery_tag = self._next_delivery_tag()
359
+ properties["delivery_tag"] = delivery_tag
360
+ # The serialized envelope's original delivery tag was created at
361
+ # publish time. VQS leases can redeliver the same envelope, so each
362
+ # receive needs a fresh local tag for Kombu ACK/reject bookkeeping.
363
+ tracked_message = vqs.Message(
364
+ payload=payload,
365
+ metadata=message.metadata,
366
+ )
367
+ lease_renewal = queue_client.run_lease_renewal(
368
+ tracked_message,
369
+ lease_duration=self.lease_duration,
370
+ )
371
+ lease_renewal.__enter__()
372
+ self._messages_by_tag[delivery_tag] = _TrackedDelivery(
373
+ message=tracked_message,
374
+ lease_renewal=lease_renewal,
375
+ queue_client=queue_client,
376
+ )
377
+ return payload
378
+
379
+ def _stop_tracking_delivery(self, delivery_tag: str) -> vqs.Message[dict[str, Any]] | None:
380
+ tracked = self._messages_by_tag.pop(delivery_tag, None)
381
+ if tracked is None:
382
+ return None
383
+ tracked.lease_renewal.stop()
384
+ return tracked.message
385
+
386
+ def _ack_tracked_delivery(self, delivery_tag: str) -> None:
387
+ tracked = self._messages_by_tag.get(delivery_tag)
388
+ if tracked is None:
389
+ return
390
+ try:
391
+ tracked.queue_client.acknowledge(tracked.message)
392
+ finally:
393
+ self._stop_tracking_delivery(delivery_tag)
394
+
395
+ def _stop_all_tracked_deliveries(self) -> None:
396
+ for delivery_tag in list(self._messages_by_tag):
397
+ self._stop_tracking_delivery(delivery_tag)
398
+
399
+ def _release_all_tracked_deliveries(self) -> None:
400
+ for delivery_tag, tracked in list(self._messages_by_tag.items()):
401
+ tracked_message = self._stop_tracking_delivery(delivery_tag)
402
+ if tracked_message is None:
403
+ continue
404
+ try:
405
+ tracked.queue_client.retry_after(tracked_message, 0)
406
+ except Exception as exc: # noqa: BLE001
407
+ debug_log(
408
+ "celery.delivery_release_failed",
409
+ delivery_tag=delivery_tag,
410
+ exception_class=exc.__class__.__name__,
411
+ exception_message=str(exc),
412
+ )
413
+
414
+ def close(self) -> None:
415
+ try:
416
+ self._release_all_tracked_deliveries()
417
+ finally:
418
+ super().close()
419
+
420
+ def _idempotency_key(self, message: dict[str, Any]) -> str | None:
421
+ if not self.use_task_id_as_idempotency_key:
422
+ return None
423
+ headers = message.get("headers")
424
+ if isinstance(headers, dict):
425
+ task_id = headers.get("id")
426
+ if task_id is not None:
427
+ return str(task_id)
428
+ properties = message.get("properties")
429
+ if isinstance(properties, dict):
430
+ correlation_id = properties.get("correlation_id")
431
+ if correlation_id is not None:
432
+ return str(correlation_id)
433
+ return None
434
+
435
+ @staticmethod
436
+ def _delivery_tag(message: dict[str, Any]) -> str | None:
437
+ properties = message.get("properties")
438
+ if not isinstance(properties, dict):
439
+ return None
440
+ delivery_tag = properties.get("delivery_tag")
441
+ if delivery_tag is None:
442
+ return None
443
+ return cast("str", delivery_tag)
444
+
445
+ def _poll_get(self, queue: str, timeout: vqs.Duration | None = None) -> dict[str, Any]:
446
+ del timeout
447
+ messages = self._poll_messages(
448
+ queue,
449
+ limit=1,
450
+ )
451
+ try:
452
+ delivery = next(messages)
453
+ except StopIteration as exc:
454
+ raise Empty from exc
455
+ return self._track_message(delivery.accept())
456
+
457
+ def _poll_messages(self, queue: str, *, limit: int) -> Iterator[Any]:
458
+ return self._queue_client.poll(
459
+ self._topic(queue),
460
+ self.consumer_group,
461
+ limit=limit,
462
+ lease_duration=self.lease_duration,
463
+ )
464
+
465
+ def _poll_queue_order(self, queues: list[str]) -> tuple[str, ...]:
466
+ if not queues:
467
+ return ()
468
+ offset = self._poll_queue_offset % len(queues)
469
+ self._poll_queue_offset = offset + 1
470
+ return tuple(queues[offset:]) + tuple(queues[:offset])
471
+
472
+ def _release_unhandled_poll_deliveries(self, deliveries: Iterator[Any]) -> None:
473
+ for delivery in deliveries:
474
+ message = delivery.accept()
475
+ try:
476
+ self._queue_client.retry_after(message, self.requeue_delay_seconds)
477
+ except Exception as exc: # noqa: BLE001
478
+ debug_log(
479
+ "celery.poll_batch_release_failed",
480
+ topic=message.metadata.topic,
481
+ consumer_group=message.metadata.consumer_group,
482
+ message_id=message.metadata.message_id,
483
+ exception_class=exc.__class__.__name__,
484
+ exception_message=str(exc),
485
+ )
486
+
487
+ def _push_get(self, queue: str, timeout: vqs.Duration | None = None) -> dict[str, Any]:
488
+ del queue, timeout
489
+ raise Empty
490
+
491
+ def _handle_push_queue_delivery(
492
+ self,
493
+ payload: dict[str, Any],
494
+ metadata: vqs.MessageMetadata,
495
+ *,
496
+ queue: str,
497
+ ) -> None:
498
+ # Bouncing a push delivery with RetryAfter is a last resort: VQS paces
499
+ # redelivery on its own schedule, which can be far slower than the
500
+ # requested delay. While this callback runs the function instance is
501
+ # guaranteed to be executing, so prefer waiting here for the handoff
502
+ # lock, consumer readiness, and prefetch capacity over bouncing.
503
+ deadline = time.monotonic() + max(self.push_handoff_wait_seconds, 0.0)
504
+ if not self._acquire_push_handoff_lock(deadline):
505
+ debug_log(
506
+ "celery.push_handoff_busy",
507
+ queue=queue,
508
+ topic=metadata.topic,
509
+ consumer_group=metadata.consumer_group,
510
+ message_id=metadata.message_id,
511
+ push_retry_delay_seconds=self.push_retry_delay_seconds,
512
+ )
513
+ raise vqs.RetryAfter(self.push_retry_delay_seconds)
514
+ try:
515
+ # Raising RetryAfter lets the queue SDK update VQS visibility when
516
+ # Kombu has no active callback or its QoS prefetch window is full.
517
+ if not self._wait_for_push_capacity(queue, deadline):
518
+ debug_log(
519
+ "celery.push_handoff_unavailable",
520
+ queue=queue,
521
+ topic=metadata.topic,
522
+ consumer_group=metadata.consumer_group,
523
+ message_id=metadata.message_id,
524
+ callback_queues=sorted(self.connection._callbacks),
525
+ consumed_queues=sorted(set(self._consumed_queues_by_tag.values())),
526
+ can_consume=self.qos.can_consume(),
527
+ push_retry_delay_seconds=self.push_retry_delay_seconds,
528
+ )
529
+ raise vqs.RetryAfter(self.push_retry_delay_seconds)
530
+
531
+ message = vqs.Message(payload=payload, metadata=metadata)
532
+ payload = self._track_message(message)
533
+ try:
534
+ # _deliver enters Kombu's normal consumer path. That path is now
535
+ # responsible for basic_ack/basic_reject, which in turn ACKs or
536
+ # changes visibility for the tracked VQS lease by delivery tag.
537
+ # This runs on the VQS subscriber callback thread, not Kombu's
538
+ # consuming thread. The per-channel lock serializes this private
539
+ # Kombu delivery/QoS path so concurrent push callbacks cannot
540
+ # pass the prefetch gate and mutate QoS bookkeeping together.
541
+ self.connection._deliver(payload, queue)
542
+ except Exception as exc:
543
+ delivery_tag = self._delivery_tag(payload)
544
+ tracked_message: vqs.Message[dict[str, Any]] | None = message
545
+ if delivery_tag is not None:
546
+ tracked_message = self._stop_tracking_delivery(delivery_tag)
547
+ if self._qos is not None:
548
+ super().basic_reject(delivery_tag, requeue=False)
549
+ if tracked_message is not None:
550
+ self._queue_client.retry_after(tracked_message, self.push_retry_delay_seconds)
551
+ debug_log(
552
+ "celery.push_handoff_failed",
553
+ queue=queue,
554
+ topic=metadata.topic,
555
+ consumer_group=metadata.consumer_group,
556
+ message_id=metadata.message_id,
557
+ callback_queues=sorted(self.connection._callbacks),
558
+ exception_class=exc.__class__.__name__,
559
+ exception_message=str(exc),
560
+ push_retry_delay_seconds=self.push_retry_delay_seconds,
561
+ )
562
+ raise
563
+ # Celery queues the ACK/reject for this delivery as a pending
564
+ # operation drained by the worker consumer loop, but on Vercel that
565
+ # thread is suspended whenever no request is executing. Settle the
566
+ # delivery now, while this push invocation still has compute, or
567
+ # the lease silently expires and the task re-runs on redelivery.
568
+ self._settle_push_delivery(payload, deadline)
569
+ finally:
570
+ self._push_handoff_lock.release()
571
+ debug_log(
572
+ "celery.push_handoff_succeeded",
573
+ queue=queue,
574
+ topic=metadata.topic,
575
+ consumer_group=metadata.consumer_group,
576
+ message_id=metadata.message_id,
577
+ callback_queues=sorted(self.connection._callbacks),
578
+ )
579
+ # Delivery succeeded into Kombu. Do not let vercel.queue auto-ACK here;
580
+ # Celery/Kombu owns manual ACK/reject semantics from this point onward.
581
+ raise vqs.Handoff
582
+
583
+ def _acquire_push_handoff_lock(self, deadline: float) -> bool:
584
+ timeout = deadline - time.monotonic()
585
+ if timeout <= 0:
586
+ return self._push_handoff_lock.acquire(blocking=False)
587
+ return self._push_handoff_lock.acquire(timeout=timeout)
588
+
589
+ def _wait_for_push_capacity(self, queue: str, deadline: float) -> bool:
590
+ while True:
591
+ if self._consumes_queue(queue) and self.qos.can_consume():
592
+ return True
593
+ # Deferred ACKs are what free prefetch capacity, and consumer
594
+ # registration happens on the worker startup thread; pump pending
595
+ # operations because the worker loop may never get scheduled
596
+ # between push requests.
597
+ _flush_embedded_worker_operations()
598
+ if self._consumes_queue(queue) and self.qos.can_consume():
599
+ return True
600
+ if time.monotonic() >= deadline:
601
+ return False
602
+ time.sleep(_PUSH_WAIT_POLL_INTERVAL_SECONDS)
603
+
604
+ def _settle_push_delivery(self, payload: dict[str, Any], deadline: float) -> None:
605
+ delivery_tag = self._delivery_tag(payload)
606
+ if delivery_tag is None:
607
+ return
608
+ while delivery_tag in self._messages_by_tag:
609
+ if not _flush_embedded_worker_operations():
610
+ # No embedded worker owns pending operations in this process
611
+ # (e.g. a standalone `celery worker`); its own consumer loop
612
+ # keeps running and will settle the delivery.
613
+ return
614
+ if delivery_tag not in self._messages_by_tag or time.monotonic() >= deadline:
615
+ return
616
+ time.sleep(_PUSH_SETTLE_POLL_INTERVAL_SECONDS)
617
+
618
+ def _register_push_channel(self) -> None:
619
+ with _push_channels_lock:
620
+ _push_channels.append(cast("PushChannel | AutoChannel", self))
621
+ debug_log(
622
+ "celery.push_channel_registered",
623
+ channel_class=self.__class__.__name__,
624
+ consumer_group=self.consumer_group,
625
+ )
626
+
627
+ def _unregister_push_channel(self) -> None:
628
+ with _push_channels_lock:
629
+ if self in _push_channels:
630
+ _push_channels.remove(cast("PushChannel | AutoChannel", self))
631
+ debug_log(
632
+ "celery.push_channel_unregistered",
633
+ channel_class=self.__class__.__name__,
634
+ consumer_group=self.consumer_group,
635
+ )
636
+
637
+
638
+ class PollChannel(_BaseChannel):
639
+ def _get(self, queue: str, timeout: vqs.Duration | None = None) -> dict[str, Any]:
640
+ return self._poll_get(queue, timeout=timeout)
641
+
642
+ def _get_many(
643
+ self,
644
+ queues: list[str],
645
+ timeout: vqs.Duration | None = None,
646
+ ) -> None:
647
+ del timeout
648
+ delivered = 0
649
+ for queue in self._poll_queue_order(queues):
650
+ remaining = self.qos.can_consume_max_estimate()
651
+ limit = 10 if remaining is None else min(remaining, 10)
652
+ if limit < 1:
653
+ break
654
+ deliveries = self._poll_messages(queue, limit=limit)
655
+ try:
656
+ for delivery in deliveries:
657
+ payload = self._track_message(delivery.accept())
658
+ self.connection._deliver(payload, queue)
659
+ delivered += 1
660
+ if not self.qos.can_consume():
661
+ break
662
+ except Exception:
663
+ self._release_unhandled_poll_deliveries(deliveries)
664
+ raise
665
+ if not self.qos.can_consume():
666
+ break
667
+ if delivered == 0:
668
+ raise Empty
669
+
670
+
671
+ class PushChannel(_BaseChannel):
672
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
673
+ super().__init__(*args, **kwargs)
674
+ self._register_push_channel()
675
+
676
+ def _get(self, queue: str, timeout: vqs.Duration | None = None) -> dict[str, Any]:
677
+ return self._push_get(queue, timeout=timeout)
678
+
679
+ def _handle_queue_delivery(
680
+ self,
681
+ payload: dict[str, Any],
682
+ metadata: vqs.MessageMetadata,
683
+ *,
684
+ queue: str,
685
+ ) -> None:
686
+ self._handle_push_queue_delivery(payload, metadata, queue=queue)
687
+
688
+ def close(self) -> None:
689
+ try:
690
+ self._unregister_push_channel()
691
+ finally:
692
+ super().close()
693
+
694
+
695
+ class AutoChannel(_BaseChannel):
696
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
697
+ super().__init__(*args, **kwargs)
698
+ if is_vercel_runtime():
699
+ self._register_push_channel()
700
+
701
+ def _get(self, queue: str, timeout: vqs.Duration | None = None) -> dict[str, Any]:
702
+ if is_vercel_runtime():
703
+ return self._push_get(queue, timeout=timeout)
704
+ return self._poll_get(queue, timeout=timeout)
705
+
706
+ def _handle_queue_delivery(
707
+ self,
708
+ payload: dict[str, Any],
709
+ metadata: vqs.MessageMetadata,
710
+ *,
711
+ queue: str,
712
+ ) -> None:
713
+ self._handle_push_queue_delivery(payload, metadata, queue=queue)
714
+
715
+ def close(self) -> None:
716
+ try:
717
+ self._unregister_push_channel()
718
+ finally:
719
+ super().close()
720
+
721
+
722
+ def _find_push_channel(queue: str, consumer_group: str) -> PushChannel | AutoChannel | None:
723
+ # Prefer a channel that can accept work immediately. If the only matching
724
+ # channel is at capacity, return it anyway so _handle_queue_delivery can use
725
+ # that channel's configured requeue delay instead of the package default.
726
+ with _push_channels_lock:
727
+ channels = tuple(_push_channels)
728
+ debug_log(
729
+ "celery.push_channel_lookup",
730
+ queue=queue,
731
+ consumer_group=consumer_group,
732
+ channel_count=len(channels),
733
+ )
734
+ for channel in reversed(channels):
735
+ if channel.closed:
736
+ continue
737
+ if channel.consumer_group != consumer_group:
738
+ continue
739
+ if channel._consumes_queue(queue) and channel.qos.can_consume():
740
+ debug_log(
741
+ "celery.push_channel_selected",
742
+ queue=queue,
743
+ consumer_group=consumer_group,
744
+ channel_class=channel.__class__.__name__,
745
+ ready=True,
746
+ )
747
+ return channel
748
+ for channel in reversed(channels):
749
+ if channel.closed:
750
+ continue
751
+ if channel.consumer_group != consumer_group:
752
+ continue
753
+ if channel._consumes_queue(queue):
754
+ debug_log(
755
+ "celery.push_channel_selected",
756
+ queue=queue,
757
+ consumer_group=consumer_group,
758
+ channel_class=channel.__class__.__name__,
759
+ ready=False,
760
+ )
761
+ return channel
762
+ debug_log(
763
+ "celery.push_channel_missing",
764
+ queue=queue,
765
+ consumer_group=consumer_group,
766
+ channel_count=len(channels),
767
+ )
768
+ return None
769
+
770
+
771
+ def _make_queue_callback(queue: str) -> Any:
772
+ def handle_queue_delivery(
773
+ message: vqs.Message[Any],
774
+ ) -> None:
775
+ # This is the real VQS trigger callback. The queue SDK has already
776
+ # accepted/deserialized the delivery and will perform follow-up lease
777
+ # actions according to the directive raised here.
778
+ consumer_group = str(message.metadata.consumer_group)
779
+ channel = _find_push_channel(queue, consumer_group)
780
+ if channel is None:
781
+ # A delivery can race worker startup on a cold boot: the embedded
782
+ # worker opens its push channel moments after the HTTP server
783
+ # starts accepting push callbacks. Waiting briefly beats bouncing,
784
+ # because VQS redelivery pacing is far coarser than this window.
785
+ deadline = time.monotonic() + _PUSH_CHANNEL_WAIT_SECONDS
786
+ while channel is None and time.monotonic() < deadline:
787
+ time.sleep(_PUSH_WAIT_POLL_INTERVAL_SECONDS)
788
+ channel = _find_push_channel(queue, consumer_group)
789
+ if channel is None:
790
+ raise vqs.RetryAfter(DEFAULT_PUSH_RETRY_DELAY_SECONDS)
791
+ channel._handle_queue_delivery(
792
+ message.payload,
793
+ message.metadata,
794
+ queue=queue,
795
+ )
796
+
797
+ handle_queue_delivery.__name__ = f"vercel_celery_{queue}_subscriber"
798
+ return handle_queue_delivery
799
+
800
+
801
+ def _flush_embedded_worker_operations() -> bool:
802
+ """Drain deferred consumer operations (ACKs/rejects) of embedded workers.
803
+
804
+ Returns whether any embedded worker consumer was available to drain.
805
+ Celery consumers defer message settlement to their consuming loop thread,
806
+ which on Vercel is suspended outside of request handling; push delivery
807
+ paths call this to run those operations on the delivering thread instead.
808
+ ``perform_pending_operations`` pops from a plain list and handles each
809
+ operation's errors, so concurrent draining by the worker loop is safe.
810
+ """
811
+ flushed = False
812
+ with _embedded_workers_lock:
813
+ embedded_workers = tuple(_embedded_workers.values())
814
+ for embedded in embedded_workers:
815
+ consumer = getattr(embedded.worker, "consumer", None)
816
+ if consumer is None:
817
+ continue
818
+ try:
819
+ consumer.perform_pending_operations()
820
+ except Exception as exc: # noqa: BLE001
821
+ debug_log(
822
+ "celery.pending_operations_flush_failed",
823
+ exception_class=exc.__class__.__name__,
824
+ exception_message=str(exc),
825
+ )
826
+ continue
827
+ flushed = True
828
+ return flushed
829
+
830
+
831
+ def _start_embedded_worker(app: Celery) -> None:
832
+ _configure_celery_debug_logging()
833
+ with _embedded_workers_lock:
834
+ if app in _embedded_workers:
835
+ debug_log("celery.embedded_worker_reused", app_main=getattr(app, "main", None))
836
+ return
837
+ loglevel = "DEBUG" if _queue_debug_enabled() else "INFO"
838
+ debug_log("celery.embedded_worker_starting", app_main=getattr(app, "main", None))
839
+ worker = app.WorkController(
840
+ concurrency=1,
841
+ hostname="vercel-celery-embedded-worker@localhost",
842
+ pool="solo",
843
+ loglevel=loglevel,
844
+ without_gossip=True,
845
+ without_heartbeat=True,
846
+ without_mingle=True,
847
+ )
848
+ thread = threading.Thread(
849
+ target=worker.start,
850
+ name="vercel-celery-embedded-worker",
851
+ daemon=True,
852
+ )
853
+ _embedded_workers[app] = _EmbeddedWorker(app=app, worker=worker, thread=thread)
854
+ try:
855
+ thread.start()
856
+ except Exception:
857
+ _embedded_workers.pop(app, None)
858
+ debug_log(
859
+ "celery.embedded_worker_thread_start_failed",
860
+ app_main=getattr(app, "main", None),
861
+ )
862
+ raise
863
+ _wait_for_embedded_worker_channel(worker)
864
+
865
+
866
+ def _wait_for_embedded_worker_channel(worker: Any) -> None:
867
+ deadline = time.monotonic() + _EMBEDDED_WORKER_STARTUP_WAIT_SECONDS
868
+ while time.monotonic() < deadline:
869
+ if _embedded_worker_channel_ready(worker):
870
+ debug_log("celery.embedded_worker_ready")
871
+ return
872
+ time.sleep(0.01)
873
+ debug_log("celery.embedded_worker_ready_timeout")
874
+
875
+
876
+ def _embedded_worker_channel_ready(worker: Any) -> bool:
877
+ consumer = getattr(worker, "consumer", None)
878
+ if consumer is None:
879
+ return False
880
+ app = getattr(worker, "app", None)
881
+ if app is None:
882
+ return False
883
+ return any(
884
+ getattr(channel, "closed", True) is False and _channel_belongs_to_app(channel, app)
885
+ for channel in tuple(_push_channels)
886
+ )
887
+
888
+
889
+ def _channel_belongs_to_app(channel: PushChannel | AutoChannel, app: Celery) -> bool:
890
+ connection = getattr(channel, "connection", None)
891
+ client = getattr(connection, "client", None)
892
+ return getattr(client, "app", None) is app
893
+
894
+
895
+ def _register_app_queue(
896
+ app: Celery,
897
+ queue: str,
898
+ consumer_group: vqs.SanitizedName,
899
+ queue_name_prefix: str,
900
+ ) -> None:
901
+ app_queues = _registered_app_queues.setdefault(app, set())
902
+ app_subscriptions = _registered_queue_subscriptions.setdefault(app, {})
903
+ app_callbacks = _registered_callbacks.setdefault(app, {})
904
+ topic = vqs.sanitize_name(f"{queue_name_prefix}{queue}")
905
+ key = (str(topic), str(consumer_group))
906
+ if key in app_queues:
907
+ return
908
+ registered_queue = _registered_queue_subscription_queue(key)
909
+ if registered_queue is not None:
910
+ if registered_queue != queue:
911
+ raise RuntimeError(
912
+ "Celery app queue registration cannot map multiple Celery queue names "
913
+ f"to Vercel Queue topic {topic!r} and consumer group {consumer_group!r}"
914
+ )
915
+ app_queues.add(key)
916
+ app_subscriptions[key] = queue
917
+ callback = _registered_queue_callback(key)
918
+ if callback is not None:
919
+ app_callbacks[key] = callback
920
+ return
921
+ callback = _make_queue_callback(queue)
922
+ vqs.subscribe(
923
+ topic=vqs.Topic(topic, transport=_KombuMessageTransport()),
924
+ consumer_group=consumer_group,
925
+ )(callback)
926
+ app_callbacks[key] = callback
927
+ app_subscriptions[key] = queue
928
+ app_queues.add(key)
929
+
930
+
931
+ def _registered_queue_subscription_queue(key: tuple[str, str]) -> str | None:
932
+ for app_subscriptions in tuple(_registered_queue_subscriptions.values()):
933
+ queue = app_subscriptions.get(key)
934
+ if queue is not None:
935
+ return queue
936
+ return None
937
+
938
+
939
+ def _registered_queue_callback(key: tuple[str, str]) -> Any | None:
940
+ for app_callbacks in tuple(_registered_callbacks.values()):
941
+ callback = app_callbacks.get(key)
942
+ if callback is not None:
943
+ return callback
944
+ return None
945
+
946
+
947
+ def _app_queue_names(app: Celery) -> list[str]:
948
+ # app.amqp.queues materializes Celery's default queue even when task_queues
949
+ # is empty. Depending on Celery/Kombu version, iterating yields either queue
950
+ # objects or mapping keys, so normalize both shapes to names.
951
+ queues = app.amqp.queues
952
+ queue_values = queues.values() if hasattr(queues, "values") else queues
953
+ names: list[str] = []
954
+ for queue in queue_values:
955
+ name = getattr(queue, "name", queue)
956
+ names.append(str(name))
957
+ return names
958
+
959
+
960
+ def register_celery_app_queues(app: Celery, *, start_worker: bool = True) -> None:
961
+ """Register a Celery app's queues as Vercel Queue topic subscribers.
962
+
963
+ Use this when ``install_vercel_celery_integration`` was called with
964
+ ``register_queues=False`` or when an app is created after automatic
965
+ finalize-hook registration is no longer appropriate. The app must use the
966
+ push transport, the auto transport while running on Vercel, or a compatible
967
+ Vercel push transport subclass. When *start_worker* is ``True`` (default),
968
+ an in-process solo Celery worker is started once for this app to consume
969
+ push deliveries.
970
+ """
971
+ if not _app_uses_queue_registration_transport(app):
972
+ raise RuntimeError(
973
+ "Celery app queue registration requires a vercel-push broker transport, "
974
+ "a vercel broker transport running on Vercel, or a Vercel push "
975
+ "transport subclass"
976
+ )
977
+ _configure_app_transport_defaults(app)
978
+ consumer_group = _app_consumer_group(app)
979
+ queue_name_prefix = _app_queue_name_prefix(app)
980
+ for queue_name in _app_queue_names(app):
981
+ _register_app_queue(app, queue_name, consumer_group, queue_name_prefix)
982
+ if start_worker:
983
+ _start_embedded_worker(app)
984
+
985
+
986
+ def _register_finalized_app_queues(app: Celery) -> None:
987
+ if not _app_uses_vercel_broker_transport(app):
988
+ return
989
+ _configure_app_transport_defaults(app)
990
+ if _finalize_hook_state.register_queues and _app_uses_queue_registration_transport(app):
991
+ register_celery_app_queues(app)
992
+
993
+
994
+ def _register_app_queues_if_eligible(app: Celery) -> None:
995
+ if not _app_uses_vercel_broker_transport(app):
996
+ return
997
+ _configure_app_transport_defaults(app)
998
+ if _app_uses_queue_registration_transport(app):
999
+ register_celery_app_queues(app)
1000
+
1001
+
1002
+ def _is_vercel_transport_url(broker_url: str) -> bool:
1003
+ transport = urlparse(broker_url).scheme
1004
+ return bool(transport) and _is_vercel_transport_name(transport)
1005
+
1006
+
1007
+ def _is_vercel_transport_name(transport: str) -> bool:
1008
+ resolved = _resolve_transport_class(transport)
1009
+ return resolved is not None and _is_vercel_transport_class(resolved)
1010
+
1011
+
1012
+ def _register_existing_app_queues() -> None:
1013
+ for app in celery_state._get_active_apps():
1014
+ _register_app_queues_if_eligible(app)
1015
+
1016
+
1017
+ def _configure_existing_app_defaults(
1018
+ *,
1019
+ broker_url: str | None = None,
1020
+ result_backend: str | None = None,
1021
+ ) -> None:
1022
+ for app in celery_state._get_active_apps():
1023
+ _configure_app_global_defaults(
1024
+ app,
1025
+ broker_url=broker_url,
1026
+ result_backend=result_backend,
1027
+ )
1028
+
1029
+
1030
+ def _install_connection_transport_options_hook() -> None:
1031
+ if _finalize_hook_state.connection_transport_options_hook_installed:
1032
+ return
1033
+ original_connection = Celery._connection
1034
+
1035
+ @wraps(original_connection)
1036
+ def _connection_with_prefix_default(
1037
+ self: Celery,
1038
+ *args: Any,
1039
+ **kwargs: Any,
1040
+ ) -> Any:
1041
+ connection = original_connection(self, *args, **kwargs)
1042
+ options = connection.transport_options
1043
+ if isinstance(options, dict) and "queue_name_prefix" not in options:
1044
+ transport = _resolve_transport_class(connection.transport_cls)
1045
+ if transport is not None and _is_vercel_transport_class(transport):
1046
+ options["queue_name_prefix"] = _app_queue_name_prefix(self)
1047
+ return connection
1048
+
1049
+ cast("Any", Celery)._connection = _connection_with_prefix_default
1050
+ _finalize_hook_state.connection_transport_options_hook_installed = True
1051
+
1052
+
1053
+ def _configure_app_global_defaults(
1054
+ app: Celery,
1055
+ *,
1056
+ broker_url: str | None,
1057
+ result_backend: str | None,
1058
+ ) -> None:
1059
+ defaults: dict[str, str] = {}
1060
+ if broker_url is not None and getattr(app.conf, "broker_url", None) is None:
1061
+ defaults["broker_url"] = broker_url
1062
+ if result_backend is not None and getattr(app.conf, "result_backend", None) is None:
1063
+ defaults["result_backend"] = result_backend
1064
+ if defaults:
1065
+ app.add_defaults(defaults)
1066
+
1067
+
1068
+ def _app_uses_vercel_broker_transport(app: Celery) -> bool:
1069
+ for transport in _broker_transport_classes(app):
1070
+ if not _is_vercel_transport_class(transport):
1071
+ continue
1072
+ return True
1073
+ return False
1074
+
1075
+
1076
+ def _app_uses_queue_registration_transport(app: Celery) -> bool:
1077
+ for transport in _broker_transport_classes(app):
1078
+ if not _is_vercel_transport_class(transport):
1079
+ continue
1080
+ channel = transport.Channel
1081
+ if issubclass(channel, PushChannel):
1082
+ return True
1083
+ if issubclass(channel, AutoChannel) and is_vercel_runtime():
1084
+ return True
1085
+ return False
1086
+
1087
+
1088
+ def _is_vercel_transport_class(transport: TransportClass) -> bool:
1089
+ if not issubclass(transport, virtual.Transport):
1090
+ return False
1091
+ channel = getattr(transport, "Channel", None)
1092
+ return isinstance(channel, type) and issubclass(channel, _BaseChannel)
1093
+
1094
+
1095
+ def _configure_app_transport_defaults(app: Celery) -> None:
1096
+ _set_app_consumer_group_default(app, _app_consumer_group(app))
1097
+ _set_app_queue_name_prefix_default(app, _app_queue_name_prefix(app))
1098
+
1099
+
1100
+ def _app_consumer_group(app: Celery) -> vqs.SanitizedName:
1101
+ options = getattr(app.conf, "broker_transport_options", None)
1102
+ if isinstance(options, Mapping):
1103
+ value = options.get("consumer_group")
1104
+ if value is not None:
1105
+ return vqs.sanitize_name(str(value))
1106
+ return vqs.SanitizedName(DEFAULT_CONSUMER_GROUP)
1107
+
1108
+
1109
+ def _app_queue_name_prefix(app: Celery) -> str:
1110
+ options = getattr(app.conf, "broker_transport_options", None)
1111
+ if isinstance(options, Mapping) and "queue_name_prefix" in options:
1112
+ value = options.get("queue_name_prefix")
1113
+ return "" if value is None else str(value)
1114
+ main = getattr(app, "main", None)
1115
+ if main:
1116
+ return f"celery-{main}-"
1117
+ return ""
1118
+
1119
+
1120
+ def _set_app_consumer_group_default(
1121
+ app: Celery,
1122
+ consumer_group: vqs.SanitizedName,
1123
+ ) -> None:
1124
+ options = getattr(app.conf, "broker_transport_options", None)
1125
+ if isinstance(options, Mapping) and options.get("consumer_group") is not None:
1126
+ return
1127
+ updated_options = dict(options) if isinstance(options, Mapping) else {}
1128
+ updated_options["consumer_group"] = str(consumer_group)
1129
+ app.conf.broker_transport_options = updated_options
1130
+
1131
+
1132
+ def _set_app_queue_name_prefix_default(
1133
+ app: Celery,
1134
+ queue_name_prefix: str,
1135
+ ) -> None:
1136
+ options = getattr(app.conf, "broker_transport_options", None)
1137
+ if isinstance(options, Mapping) and "queue_name_prefix" in options:
1138
+ return
1139
+ updated_options = dict(options) if isinstance(options, Mapping) else {}
1140
+ updated_options["queue_name_prefix"] = queue_name_prefix
1141
+ app.conf.broker_transport_options = updated_options
1142
+
1143
+
1144
+ def _broker_transport_classes(app: Celery) -> Iterator[TransportClass]:
1145
+ conf = app.conf
1146
+ broker_transport = getattr(conf, "broker_transport", None)
1147
+ if isinstance(broker_transport, str):
1148
+ if resolved := _resolve_transport_class(broker_transport):
1149
+ yield resolved
1150
+ elif isinstance(broker_transport, type):
1151
+ yield broker_transport
1152
+
1153
+ for name in ("broker_read_url", "broker_write_url", "broker_url"):
1154
+ for broker_url in _broker_urls(getattr(conf, name, None)):
1155
+ transport: str = urlparse(broker_url).scheme
1156
+ if transport and (resolved := _resolve_transport_class(transport)):
1157
+ yield resolved
1158
+
1159
+
1160
+ def _broker_urls(value: object) -> tuple[str, ...]:
1161
+ if value is None:
1162
+ return ()
1163
+ if isinstance(value, str):
1164
+ return tuple(part for part in value.split(";") if part)
1165
+ if isinstance(value, (list, tuple)):
1166
+ urls: list[str] = []
1167
+ for item in value:
1168
+ urls.extend(_broker_urls(item))
1169
+ return tuple(urls)
1170
+ return ()
1171
+
1172
+
1173
+ def _resolve_transport_class(transport: str) -> TransportClass | None:
1174
+ try:
1175
+ resolved = resolve_transport(transport)
1176
+ except (AttributeError, ImportError, KeyError, TypeError):
1177
+ return None
1178
+ return resolved if isinstance(resolved, type) else None
1179
+
1180
+
1181
+ def _install_app_finalize_hook(*, register_queues: bool) -> None:
1182
+ _finalize_hook_state.register_queues = _finalize_hook_state.register_queues or register_queues
1183
+ if _finalize_hook_state.installed:
1184
+ return
1185
+ # Celery exposes finalized apps through this internal hook; Celery itself
1186
+ # uses it for shared task binding. Installing here lets users define their
1187
+ # app normally and still get concrete VQS subscriptions once Celery has
1188
+ # synthesized the final queue set.
1189
+ celery_state.connect_on_app_finalize(_register_finalized_app_queues)
1190
+ _finalize_hook_state.installed = True
1191
+
1192
+
1193
+ __all__ = [
1194
+ "__version__",
1195
+ "_configure_existing_app_defaults",
1196
+ "_register_existing_app_queues",
1197
+ "_set_default_broker_set_by_installer",
1198
+ "register_celery_app_queues",
1199
+ ]