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.
- vercel/integrations/celery/__init__.py +141 -0
- vercel/integrations/celery/_broker.py +1199 -0
- vercel/integrations/celery/_result_backend.py +198 -0
- vercel/integrations/celery/_vendor/__init__.py +1 -0
- vercel/integrations/celery/py.typed +0 -0
- vercel/integrations/celery/version.py +3 -0
- vercel_celery_bundle-0.7.1.dist-info/METADATA +95 -0
- vercel_celery_bundle-0.7.1.dist-info/RECORD +10 -0
- vercel_celery_bundle-0.7.1.dist-info/WHEEL +4 -0
- vercel_celery_bundle-0.7.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|