taskiq-llm-queue 0.1.0a1__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.
- taskiq_llm_queue/__init__.py +138 -0
- taskiq_llm_queue/_logging.py +40 -0
- taskiq_llm_queue/_version.py +5 -0
- taskiq_llm_queue/broker/__init__.py +86 -0
- taskiq_llm_queue/broker/acker.py +239 -0
- taskiq_llm_queue/broker/batching.py +188 -0
- taskiq_llm_queue/broker/broker.py +937 -0
- taskiq_llm_queue/broker/delivery.py +50 -0
- taskiq_llm_queue/broker/errors.py +178 -0
- taskiq_llm_queue/broker/events.py +51 -0
- taskiq_llm_queue/broker/filters.py +32 -0
- taskiq_llm_queue/broker/heartbeat.py +157 -0
- taskiq_llm_queue/broker/queue.py +141 -0
- taskiq_llm_queue/broker/s3_offload.py +346 -0
- taskiq_llm_queue/broker/scheduler.py +113 -0
- taskiq_llm_queue/broker/serializer.py +32 -0
- taskiq_llm_queue/cli.py +488 -0
- taskiq_llm_queue/client.py +1409 -0
- taskiq_llm_queue/config.py +592 -0
- taskiq_llm_queue/context.py +62 -0
- taskiq_llm_queue/core.py +832 -0
- taskiq_llm_queue/envelope.py +136 -0
- taskiq_llm_queue/errors.py +71 -0
- taskiq_llm_queue/failover.py +286 -0
- taskiq_llm_queue/http_transport.py +220 -0
- taskiq_llm_queue/keys.py +87 -0
- taskiq_llm_queue/langchain/__init__.py +64 -0
- taskiq_llm_queue/langchain/_client.py +142 -0
- taskiq_llm_queue/langchain/bedrock.py +291 -0
- taskiq_llm_queue/langchain/openai.py +103 -0
- taskiq_llm_queue/lifecycle.py +512 -0
- taskiq_llm_queue/metrics/__init__.py +45 -0
- taskiq_llm_queue/metrics/emf.py +100 -0
- taskiq_llm_queue/metrics/prometheus.py +66 -0
- taskiq_llm_queue/metrics_names.py +19 -0
- taskiq_llm_queue/protocols.py +140 -0
- taskiq_llm_queue/providers/__init__.py +62 -0
- taskiq_llm_queue/providers/bedrock.py +188 -0
- taskiq_llm_queue/providers/classify.py +251 -0
- taskiq_llm_queue/providers/embeddings.py +87 -0
- taskiq_llm_queue/providers/hosts.py +34 -0
- taskiq_llm_queue/providers/openai_compat.py +142 -0
- taskiq_llm_queue/providers/registry.py +173 -0
- taskiq_llm_queue/py.typed +0 -0
- taskiq_llm_queue/reliability/__init__.py +59 -0
- taskiq_llm_queue/reliability/_lua.py +461 -0
- taskiq_llm_queue/reliability/breaker.py +86 -0
- taskiq_llm_queue/reliability/claims.py +226 -0
- taskiq_llm_queue/reliability/config_store.py +148 -0
- taskiq_llm_queue/reliability/fallback.py +111 -0
- taskiq_llm_queue/reliability/limiter.py +482 -0
- taskiq_llm_queue/reliability/local_limiter.py +153 -0
- taskiq_llm_queue/results/__init__.py +25 -0
- taskiq_llm_queue/results/backend.py +226 -0
- taskiq_llm_queue/results/dispatcher.py +366 -0
- taskiq_llm_queue/results/s3_backend.py +105 -0
- taskiq_llm_queue/routing.py +173 -0
- taskiq_llm_queue/routing_hooks.py +90 -0
- taskiq_llm_queue/tasks.py +453 -0
- taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
- taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
- taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
- taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""taskiq-llm-queue: an LLM request client with its own durable queue (taskiq + Amazon SQS).
|
|
2
|
+
|
|
3
|
+
This is the public front door: `__all__` lists the client surface (the client and its
|
|
4
|
+
results, config, request context, errors and extension points). Wire and plumbing names live
|
|
5
|
+
in their submodules (`envelope`, `keys`, `protocols`, `errors`, `routing_hooks`). Importing
|
|
6
|
+
the package has no side effects: no clients, threads or network I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from taskiq_llm_queue._version import __version__
|
|
15
|
+
from taskiq_llm_queue.config import (
|
|
16
|
+
BreakerTuning,
|
|
17
|
+
Lane,
|
|
18
|
+
LimiterTuning,
|
|
19
|
+
ModelConfig,
|
|
20
|
+
QueueConfig,
|
|
21
|
+
Target,
|
|
22
|
+
)
|
|
23
|
+
from taskiq_llm_queue.context import RequestContext, current_context, request_context
|
|
24
|
+
from taskiq_llm_queue.envelope import CallMeta, FallbackPolicy
|
|
25
|
+
from taskiq_llm_queue.errors import (
|
|
26
|
+
ConfigError,
|
|
27
|
+
LLMQueueCancelled,
|
|
28
|
+
LLMQueueError,
|
|
29
|
+
LLMQueueTimeout,
|
|
30
|
+
ProviderError,
|
|
31
|
+
QueueUnavailable,
|
|
32
|
+
UnsupportedEnvelopeError,
|
|
33
|
+
)
|
|
34
|
+
from taskiq_llm_queue.routing_hooks import FailoverPolicy, PathRouter, QueueHooks
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from taskiq_llm_queue.client import (
|
|
38
|
+
ChatResult,
|
|
39
|
+
ConverseResult,
|
|
40
|
+
EmbedResult,
|
|
41
|
+
InvokeResult,
|
|
42
|
+
LLMQueue,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Plumbing kept importable for compatibility (typed for mypy; not in __all__).
|
|
46
|
+
from taskiq_llm_queue.envelope import ENVELOPE_VERSION as ENVELOPE_VERSION
|
|
47
|
+
from taskiq_llm_queue.envelope import TASK_CALL as TASK_CALL
|
|
48
|
+
from taskiq_llm_queue.envelope import TASK_EMBED as TASK_EMBED
|
|
49
|
+
from taskiq_llm_queue.envelope import TASK_JOB as TASK_JOB
|
|
50
|
+
from taskiq_llm_queue.envelope import Envelope as Envelope
|
|
51
|
+
from taskiq_llm_queue.envelope import LLMReply as LLMReply
|
|
52
|
+
from taskiq_llm_queue.envelope import parse_envelope as parse_envelope
|
|
53
|
+
from taskiq_llm_queue.errors import ErrorInfo as ErrorInfo
|
|
54
|
+
from taskiq_llm_queue.errors import ErrorKind as ErrorKind
|
|
55
|
+
from taskiq_llm_queue.keys import KeySpace as KeySpace
|
|
56
|
+
from taskiq_llm_queue.protocols import ClaimDecision as ClaimDecision
|
|
57
|
+
from taskiq_llm_queue.protocols import ClaimState as ClaimState
|
|
58
|
+
from taskiq_llm_queue.protocols import ClaimStore as ClaimStore
|
|
59
|
+
from taskiq_llm_queue.protocols import Lease as Lease
|
|
60
|
+
from taskiq_llm_queue.protocols import Limiter as Limiter
|
|
61
|
+
from taskiq_llm_queue.protocols import Provider as Provider
|
|
62
|
+
from taskiq_llm_queue.protocols import ReceiveGate as ReceiveGate
|
|
63
|
+
from taskiq_llm_queue.protocols import ResultStore as ResultStore
|
|
64
|
+
from taskiq_llm_queue.routing_hooks import CallPath as CallPath
|
|
65
|
+
|
|
66
|
+
# Loaded on first access: the client pulls in taskiq, redis and aiobotocore, and importing the
|
|
67
|
+
# package must stay free of them (library rule; see tests/unit/test_contracts.py).
|
|
68
|
+
_CLIENT = "taskiq_llm_queue.client"
|
|
69
|
+
_LAZY = {
|
|
70
|
+
"LLMQueue": _CLIENT,
|
|
71
|
+
"ConverseResult": _CLIENT,
|
|
72
|
+
"ChatResult": _CLIENT,
|
|
73
|
+
"InvokeResult": _CLIENT,
|
|
74
|
+
"EmbedResult": _CLIENT,
|
|
75
|
+
}
|
|
76
|
+
# Wire / plumbing names that used to be re-exported here: still importable from the package
|
|
77
|
+
# for compatibility, but not public API; import them from their submodules.
|
|
78
|
+
_PLUMBING = {
|
|
79
|
+
"ENVELOPE_VERSION": "taskiq_llm_queue.envelope",
|
|
80
|
+
"TASK_CALL": "taskiq_llm_queue.envelope",
|
|
81
|
+
"TASK_EMBED": "taskiq_llm_queue.envelope",
|
|
82
|
+
"TASK_JOB": "taskiq_llm_queue.envelope",
|
|
83
|
+
"Envelope": "taskiq_llm_queue.envelope",
|
|
84
|
+
"LLMReply": "taskiq_llm_queue.envelope",
|
|
85
|
+
"parse_envelope": "taskiq_llm_queue.envelope",
|
|
86
|
+
"KeySpace": "taskiq_llm_queue.keys",
|
|
87
|
+
"ErrorInfo": "taskiq_llm_queue.errors",
|
|
88
|
+
"ErrorKind": "taskiq_llm_queue.errors",
|
|
89
|
+
"CallPath": "taskiq_llm_queue.routing_hooks",
|
|
90
|
+
"ClaimDecision": "taskiq_llm_queue.protocols",
|
|
91
|
+
"ClaimState": "taskiq_llm_queue.protocols",
|
|
92
|
+
"ClaimStore": "taskiq_llm_queue.protocols",
|
|
93
|
+
"Lease": "taskiq_llm_queue.protocols",
|
|
94
|
+
"Limiter": "taskiq_llm_queue.protocols",
|
|
95
|
+
"Provider": "taskiq_llm_queue.protocols",
|
|
96
|
+
"ReceiveGate": "taskiq_llm_queue.protocols",
|
|
97
|
+
"ResultStore": "taskiq_llm_queue.protocols",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"BreakerTuning",
|
|
102
|
+
"CallMeta",
|
|
103
|
+
"ChatResult",
|
|
104
|
+
"ConfigError",
|
|
105
|
+
"ConverseResult",
|
|
106
|
+
"EmbedResult",
|
|
107
|
+
"FailoverPolicy",
|
|
108
|
+
"FallbackPolicy",
|
|
109
|
+
"InvokeResult",
|
|
110
|
+
"LLMQueue",
|
|
111
|
+
"LLMQueueCancelled",
|
|
112
|
+
"LLMQueueError",
|
|
113
|
+
"LLMQueueTimeout",
|
|
114
|
+
"Lane",
|
|
115
|
+
"LimiterTuning",
|
|
116
|
+
"ModelConfig",
|
|
117
|
+
"PathRouter",
|
|
118
|
+
"ProviderError",
|
|
119
|
+
"QueueConfig",
|
|
120
|
+
"QueueHooks",
|
|
121
|
+
"QueueUnavailable",
|
|
122
|
+
"RequestContext",
|
|
123
|
+
"Target",
|
|
124
|
+
"UnsupportedEnvelopeError",
|
|
125
|
+
"__version__",
|
|
126
|
+
"current_context",
|
|
127
|
+
"request_context",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def __getattr__(name: str) -> Any:
|
|
132
|
+
"""Import the client on first use of `LLMQueue` / a result type; plumbing names likewise."""
|
|
133
|
+
module = _LAZY.get(name) or _PLUMBING.get(name)
|
|
134
|
+
if module is None:
|
|
135
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
136
|
+
value = getattr(import_module(module), name)
|
|
137
|
+
globals()[name] = value
|
|
138
|
+
return value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Structured loggers that always go through stdlib `logging` (no global structlog config).
|
|
2
|
+
|
|
3
|
+
Unconfigured structlog prints to stdout and ignores the app's levels and handlers, so every
|
|
4
|
+
module logs via `get_logger(__name__)`: structlog's key-value API on top of a stdlib logger.
|
|
5
|
+
Apps keep full control (levels, handlers, loguru's InterceptHandler, structlog's
|
|
6
|
+
ProcessorFormatter); the fields ride on `record.llmq` as a dict.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Mapping, MutableMapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
_PASSTHROUGH = ("exc_info", "stack_info")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_stdlib(
|
|
21
|
+
_logger: Any, _method: str, event_dict: MutableMapping[str, Any]
|
|
22
|
+
) -> tuple[tuple[str], dict[str, Any]]:
|
|
23
|
+
# Message is readable on its own ("event k=v ..."); the structured copy lives on one
|
|
24
|
+
# `llmq` attribute so field names can never clash with LogRecord's reserved attributes.
|
|
25
|
+
event = str(event_dict.pop("event", ""))
|
|
26
|
+
kwargs: dict[str, Any] = {k: event_dict.pop(k) for k in _PASSTHROUGH if k in event_dict}
|
|
27
|
+
fields: Mapping[str, Any] = dict(event_dict)
|
|
28
|
+
message = " ".join([event, *(f"{k}={v}" for k, v in fields.items())])
|
|
29
|
+
kwargs["extra"] = {"llmq": {"event": event, **fields}}
|
|
30
|
+
kwargs["stacklevel"] = 4 # point %(funcName)s/%(lineno)d at the library call site
|
|
31
|
+
return (message,), kwargs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
35
|
+
"""Key-value logger bound to `logging.getLogger(name)`; used by every library module."""
|
|
36
|
+
return structlog.wrap_logger( # type: ignore[no-any-return]
|
|
37
|
+
logging.getLogger(name),
|
|
38
|
+
processors=[_to_stdlib],
|
|
39
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
40
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""SQS broker for taskiq with priority, capacity-aware receive and batching (see docs/DESIGN.md).
|
|
2
|
+
|
|
3
|
+
`HybridSQSBroker` plus its settings (`SQSQueue`, `Batching`, `Heartbeat`), event hooks and
|
|
4
|
+
the SQS error-family classifier. Importing this package creates no client and does no I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from taskiq_llm_queue.broker.acker import VisibilityResult
|
|
10
|
+
from taskiq_llm_queue.broker.broker import (
|
|
11
|
+
DEDUPLICATION_ID_LABEL,
|
|
12
|
+
DELAY_LABEL,
|
|
13
|
+
EXPIRY_ATTRIBUTE,
|
|
14
|
+
EXPIRY_LABEL,
|
|
15
|
+
GROUP_ID_LABEL,
|
|
16
|
+
QUEUE_LABEL,
|
|
17
|
+
SKIP_BATCHING_LABEL,
|
|
18
|
+
SQS_MESSAGE_ID_LABEL,
|
|
19
|
+
SQS_QUEUE_LABEL,
|
|
20
|
+
SQS_RECEIPT_HANDLE_LABEL,
|
|
21
|
+
SQS_RECEIVE_COUNT_LABEL,
|
|
22
|
+
DeliveryFormatter,
|
|
23
|
+
HybridSQSBroker,
|
|
24
|
+
ReceiveBudget,
|
|
25
|
+
)
|
|
26
|
+
from taskiq_llm_queue.broker.delivery import Delivery
|
|
27
|
+
from taskiq_llm_queue.broker.errors import (
|
|
28
|
+
BrokerConfigError,
|
|
29
|
+
ErrorFamily,
|
|
30
|
+
InvalidLabelError,
|
|
31
|
+
MessageTooLargeError,
|
|
32
|
+
SQSSendError,
|
|
33
|
+
backoff_delay,
|
|
34
|
+
classify_code,
|
|
35
|
+
classify_error,
|
|
36
|
+
)
|
|
37
|
+
from taskiq_llm_queue.broker.events import BrokerHooks
|
|
38
|
+
from taskiq_llm_queue.broker.filters import MessageFilter, envelope_supported
|
|
39
|
+
from taskiq_llm_queue.broker.queue import Batching, Heartbeat, SQSQueue
|
|
40
|
+
from taskiq_llm_queue.broker.s3_offload import (
|
|
41
|
+
OFFLOAD_LABEL,
|
|
42
|
+
OffloadedPayloadMissingError,
|
|
43
|
+
S3OffloadError,
|
|
44
|
+
S3OffloadMiddleware,
|
|
45
|
+
)
|
|
46
|
+
from taskiq_llm_queue.broker.scheduler import SlotScheduler
|
|
47
|
+
from taskiq_llm_queue.broker.serializer import WireSerializer
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"DEDUPLICATION_ID_LABEL",
|
|
51
|
+
"DELAY_LABEL",
|
|
52
|
+
"EXPIRY_ATTRIBUTE",
|
|
53
|
+
"EXPIRY_LABEL",
|
|
54
|
+
"GROUP_ID_LABEL",
|
|
55
|
+
"OFFLOAD_LABEL",
|
|
56
|
+
"QUEUE_LABEL",
|
|
57
|
+
"SKIP_BATCHING_LABEL",
|
|
58
|
+
"SQS_MESSAGE_ID_LABEL",
|
|
59
|
+
"SQS_QUEUE_LABEL",
|
|
60
|
+
"SQS_RECEIPT_HANDLE_LABEL",
|
|
61
|
+
"SQS_RECEIVE_COUNT_LABEL",
|
|
62
|
+
"Batching",
|
|
63
|
+
"BrokerConfigError",
|
|
64
|
+
"BrokerHooks",
|
|
65
|
+
"Delivery",
|
|
66
|
+
"DeliveryFormatter",
|
|
67
|
+
"ErrorFamily",
|
|
68
|
+
"Heartbeat",
|
|
69
|
+
"HybridSQSBroker",
|
|
70
|
+
"InvalidLabelError",
|
|
71
|
+
"MessageFilter",
|
|
72
|
+
"MessageTooLargeError",
|
|
73
|
+
"OffloadedPayloadMissingError",
|
|
74
|
+
"ReceiveBudget",
|
|
75
|
+
"S3OffloadError",
|
|
76
|
+
"S3OffloadMiddleware",
|
|
77
|
+
"SQSQueue",
|
|
78
|
+
"SQSSendError",
|
|
79
|
+
"SlotScheduler",
|
|
80
|
+
"VisibilityResult",
|
|
81
|
+
"WireSerializer",
|
|
82
|
+
"backoff_delay",
|
|
83
|
+
"classify_code",
|
|
84
|
+
"classify_error",
|
|
85
|
+
"envelope_supported",
|
|
86
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Batched acks (`DeleteMessageBatch`) and visibility changes (`ChangeMessageVisibilityBatch`).
|
|
2
|
+
|
|
3
|
+
At 10K messages in flight one call per message is too many requests, so acks, heartbeats
|
|
4
|
+
and releases go through per-queue micro-batches (A6). Failed batch entries are retried one
|
|
5
|
+
by one; an invalid receipt handle on delete is fine (the message is already gone or owned
|
|
6
|
+
elsewhere and its claim is `done`, DS7), while on a visibility change it means "lost" (DS6).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from taskiq_llm_queue._logging import get_logger
|
|
17
|
+
from taskiq_llm_queue.broker.batching import MicroBatcher
|
|
18
|
+
from taskiq_llm_queue.broker.errors import (
|
|
19
|
+
ErrorFamily,
|
|
20
|
+
backoff_delay,
|
|
21
|
+
classify_code,
|
|
22
|
+
classify_error,
|
|
23
|
+
error_code,
|
|
24
|
+
)
|
|
25
|
+
from taskiq_llm_queue.broker.events import BrokerHooks, safe_call
|
|
26
|
+
from taskiq_llm_queue.broker.queue import MAX_BATCH_ENTRIES
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from types_aiobotocore_sqs.client import SQSClient
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VisibilityResult(StrEnum):
|
|
35
|
+
"""Outcome of one visibility change: done, handle invalid (lost), or failed (retry later)."""
|
|
36
|
+
|
|
37
|
+
OK = "ok"
|
|
38
|
+
LOST = "lost"
|
|
39
|
+
FAILED = "failed"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Acker:
|
|
43
|
+
"""Owns the delete and change-visibility batchers for every queue of one broker.
|
|
44
|
+
|
|
45
|
+
`delete` and `change_visibility` return futures immediately; `flush` and `close` push
|
|
46
|
+
out whatever is queued (used on shutdown and drain, A5).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client: SQSClient,
|
|
52
|
+
*,
|
|
53
|
+
hooks: BrokerHooks,
|
|
54
|
+
ack_flush_secs: float = 0.5,
|
|
55
|
+
visibility_flush_secs: float = 0.5,
|
|
56
|
+
retry_attempts: int = 3,
|
|
57
|
+
backoff_base_secs: float = 0.2,
|
|
58
|
+
backoff_cap_secs: float = 2.0,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Create no batchers yet; one pair is made per queue on first use.
|
|
61
|
+
|
|
62
|
+
`retry_attempts` is the only throttle/5xx retry layer: the broker's SQS client makes at
|
|
63
|
+
most one quick botocore retry (standard mode), so one call costs <= 2 x this many.
|
|
64
|
+
"""
|
|
65
|
+
self._client = client
|
|
66
|
+
self._hooks = hooks
|
|
67
|
+
self._ack_flush_secs = ack_flush_secs
|
|
68
|
+
self._visibility_flush_secs = visibility_flush_secs
|
|
69
|
+
self._retry_attempts = retry_attempts
|
|
70
|
+
self._backoff_base_secs = backoff_base_secs
|
|
71
|
+
self._backoff_cap_secs = backoff_cap_secs
|
|
72
|
+
self._deleters: dict[str, MicroBatcher[str, bool]] = {}
|
|
73
|
+
self._changers: dict[str, MicroBatcher[tuple[str, int], VisibilityResult]] = {}
|
|
74
|
+
|
|
75
|
+
def delete(self, queue: str, url: str, receipt_handle: str) -> asyncio.Future[bool]:
|
|
76
|
+
"""Queue one ack; resolves True when deleted or already gone, False on failure."""
|
|
77
|
+
batcher = self._deleters.get(url)
|
|
78
|
+
if batcher is None:
|
|
79
|
+
batcher = MicroBatcher[str, bool](
|
|
80
|
+
lambda items: self._delete_batch(queue, url, items),
|
|
81
|
+
max_items=MAX_BATCH_ENTRIES,
|
|
82
|
+
max_delay_secs=self._ack_flush_secs,
|
|
83
|
+
name=f"ack:{queue}",
|
|
84
|
+
)
|
|
85
|
+
self._deleters[url] = batcher
|
|
86
|
+
return batcher.submit(receipt_handle)
|
|
87
|
+
|
|
88
|
+
def change_visibility(
|
|
89
|
+
self, queue: str, url: str, receipt_handle: str, visibility_secs: int
|
|
90
|
+
) -> asyncio.Future[VisibilityResult]:
|
|
91
|
+
"""Queue one visibility change (heartbeat extend, or 0 / N for a release)."""
|
|
92
|
+
batcher = self._changers.get(url)
|
|
93
|
+
if batcher is None:
|
|
94
|
+
batcher = MicroBatcher[tuple[str, int], VisibilityResult](
|
|
95
|
+
lambda items: self._visibility_batch(queue, url, items),
|
|
96
|
+
max_items=MAX_BATCH_ENTRIES,
|
|
97
|
+
max_delay_secs=self._visibility_flush_secs,
|
|
98
|
+
name=f"visibility:{queue}",
|
|
99
|
+
)
|
|
100
|
+
self._changers[url] = batcher
|
|
101
|
+
return batcher.submit((receipt_handle, visibility_secs))
|
|
102
|
+
|
|
103
|
+
async def flush(self) -> None:
|
|
104
|
+
"""Send every queued ack and visibility change now and wait for the results."""
|
|
105
|
+
batchers: list[MicroBatcher[Any, Any]] = [
|
|
106
|
+
*self._changers.values(),
|
|
107
|
+
*self._deleters.values(),
|
|
108
|
+
]
|
|
109
|
+
await asyncio.gather(*(b.flush_now() for b in batchers))
|
|
110
|
+
|
|
111
|
+
async def close(self) -> None:
|
|
112
|
+
"""Flush and stop all batchers; idempotent."""
|
|
113
|
+
batchers: list[MicroBatcher[Any, Any]] = [
|
|
114
|
+
*self._changers.values(),
|
|
115
|
+
*self._deleters.values(),
|
|
116
|
+
]
|
|
117
|
+
await asyncio.gather(*(b.close() for b in batchers))
|
|
118
|
+
self._deleters.clear()
|
|
119
|
+
self._changers.clear()
|
|
120
|
+
|
|
121
|
+
def _batchers(self) -> list[MicroBatcher[Any, Any]]:
|
|
122
|
+
return [*self._changers.values(), *self._deleters.values()]
|
|
123
|
+
|
|
124
|
+
async def _delete_batch(self, queue: str, url: str, handles: Sequence[str]) -> list[bool]:
|
|
125
|
+
entries: list[Any] = [{"Id": str(i), "ReceiptHandle": h} for i, h in enumerate(handles)]
|
|
126
|
+
resp = await self.call(
|
|
127
|
+
queue,
|
|
128
|
+
"delete_batch",
|
|
129
|
+
lambda: self._client.delete_message_batch(QueueUrl=url, Entries=entries),
|
|
130
|
+
)
|
|
131
|
+
if resp is None:
|
|
132
|
+
return [False] * len(handles)
|
|
133
|
+
results = [True] * len(handles)
|
|
134
|
+
for failed in resp.get("Failed", []):
|
|
135
|
+
index = int(failed["Id"])
|
|
136
|
+
family = classify_code(failed.get("Code", ""), failed.get("Message", ""))
|
|
137
|
+
if family is ErrorFamily.INVALID_HANDLE:
|
|
138
|
+
logger.debug("sqs_ack_handle_invalid", queue=queue)
|
|
139
|
+
continue
|
|
140
|
+
results[index] = await self._delete_one(queue, url, handles[index])
|
|
141
|
+
acked = sum(results)
|
|
142
|
+
if acked:
|
|
143
|
+
safe_call(self._hooks.on_ack, queue, acked)
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
async def _delete_one(self, queue: str, url: str, handle: str) -> bool:
|
|
147
|
+
try:
|
|
148
|
+
await self.call(
|
|
149
|
+
queue,
|
|
150
|
+
"delete",
|
|
151
|
+
lambda: self._client.delete_message(QueueUrl=url, ReceiptHandle=handle),
|
|
152
|
+
raise_final=True,
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
if classify_error(exc) is ErrorFamily.INVALID_HANDLE:
|
|
156
|
+
return True
|
|
157
|
+
logger.warning("sqs_ack_failed", queue=queue, error=str(exc))
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
async def _visibility_batch(
|
|
162
|
+
self, queue: str, url: str, items: Sequence[tuple[str, int]]
|
|
163
|
+
) -> list[VisibilityResult]:
|
|
164
|
+
entries: list[Any] = [
|
|
165
|
+
{"Id": str(i), "ReceiptHandle": h, "VisibilityTimeout": secs}
|
|
166
|
+
for i, (h, secs) in enumerate(items)
|
|
167
|
+
]
|
|
168
|
+
resp = await self.call(
|
|
169
|
+
queue,
|
|
170
|
+
"visibility_batch",
|
|
171
|
+
lambda: self._client.change_message_visibility_batch(QueueUrl=url, Entries=entries),
|
|
172
|
+
)
|
|
173
|
+
if resp is None:
|
|
174
|
+
return [VisibilityResult.FAILED] * len(items)
|
|
175
|
+
results = [VisibilityResult.OK] * len(items)
|
|
176
|
+
for failed in resp.get("Failed", []):
|
|
177
|
+
index = int(failed["Id"])
|
|
178
|
+
family = classify_code(failed.get("Code", ""), failed.get("Message", ""))
|
|
179
|
+
if family is ErrorFamily.INVALID_HANDLE:
|
|
180
|
+
results[index] = VisibilityResult.LOST
|
|
181
|
+
else:
|
|
182
|
+
results[index] = await self._visibility_one(queue, url, *items[index])
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
async def _visibility_one(
|
|
186
|
+
self, queue: str, url: str, handle: str, secs: int
|
|
187
|
+
) -> VisibilityResult:
|
|
188
|
+
try:
|
|
189
|
+
await self.call(
|
|
190
|
+
queue,
|
|
191
|
+
"visibility",
|
|
192
|
+
lambda: self._client.change_message_visibility(
|
|
193
|
+
QueueUrl=url, ReceiptHandle=handle, VisibilityTimeout=secs
|
|
194
|
+
),
|
|
195
|
+
raise_final=True,
|
|
196
|
+
)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
if classify_error(exc) is ErrorFamily.INVALID_HANDLE:
|
|
199
|
+
return VisibilityResult.LOST
|
|
200
|
+
return VisibilityResult.FAILED
|
|
201
|
+
return VisibilityResult.OK
|
|
202
|
+
|
|
203
|
+
async def call(
|
|
204
|
+
self,
|
|
205
|
+
queue: str,
|
|
206
|
+
operation: str,
|
|
207
|
+
call: Callable[[], Awaitable[Any]],
|
|
208
|
+
*,
|
|
209
|
+
raise_final: bool = False,
|
|
210
|
+
) -> Any:
|
|
211
|
+
"""Run one SQS call, retrying throttles and 5xx with jittered backoff (DS16)."""
|
|
212
|
+
attempt = 0
|
|
213
|
+
while True:
|
|
214
|
+
try:
|
|
215
|
+
return await call()
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
family = classify_error(exc)
|
|
218
|
+
safe_call(self._hooks.on_error, queue, operation, family.value)
|
|
219
|
+
if family.retryable and attempt + 1 < self._retry_attempts:
|
|
220
|
+
await asyncio.sleep(
|
|
221
|
+
backoff_delay(
|
|
222
|
+
attempt,
|
|
223
|
+
base_secs=self._backoff_base_secs,
|
|
224
|
+
cap_secs=self._backoff_cap_secs,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
attempt += 1
|
|
228
|
+
continue
|
|
229
|
+
if raise_final:
|
|
230
|
+
raise
|
|
231
|
+
logger.warning(
|
|
232
|
+
"sqs_call_failed",
|
|
233
|
+
operation=operation,
|
|
234
|
+
queue=queue,
|
|
235
|
+
error_code=error_code(exc),
|
|
236
|
+
attempt=attempt + 1,
|
|
237
|
+
error=str(exc),
|
|
238
|
+
)
|
|
239
|
+
return None
|