audex 1.0.7a3__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.
- audex/__init__.py +9 -0
- audex/__main__.py +7 -0
- audex/cli/__init__.py +189 -0
- audex/cli/apis/__init__.py +12 -0
- audex/cli/apis/init/__init__.py +34 -0
- audex/cli/apis/init/gencfg.py +130 -0
- audex/cli/apis/init/setup.py +330 -0
- audex/cli/apis/init/vprgroup.py +125 -0
- audex/cli/apis/serve.py +141 -0
- audex/cli/args.py +356 -0
- audex/cli/exceptions.py +44 -0
- audex/cli/helper/__init__.py +0 -0
- audex/cli/helper/ansi.py +193 -0
- audex/cli/helper/display.py +288 -0
- audex/config/__init__.py +64 -0
- audex/config/core/__init__.py +30 -0
- audex/config/core/app.py +29 -0
- audex/config/core/audio.py +45 -0
- audex/config/core/logging.py +163 -0
- audex/config/core/session.py +11 -0
- audex/config/helper/__init__.py +1 -0
- audex/config/helper/client/__init__.py +1 -0
- audex/config/helper/client/http.py +28 -0
- audex/config/helper/client/websocket.py +21 -0
- audex/config/helper/provider/__init__.py +1 -0
- audex/config/helper/provider/dashscope.py +13 -0
- audex/config/helper/provider/unisound.py +18 -0
- audex/config/helper/provider/xfyun.py +23 -0
- audex/config/infrastructure/__init__.py +31 -0
- audex/config/infrastructure/cache.py +51 -0
- audex/config/infrastructure/database.py +48 -0
- audex/config/infrastructure/recorder.py +32 -0
- audex/config/infrastructure/store.py +19 -0
- audex/config/provider/__init__.py +18 -0
- audex/config/provider/transcription.py +109 -0
- audex/config/provider/vpr.py +99 -0
- audex/container.py +40 -0
- audex/entity/__init__.py +468 -0
- audex/entity/doctor.py +109 -0
- audex/entity/doctor.pyi +51 -0
- audex/entity/fields.py +401 -0
- audex/entity/segment.py +115 -0
- audex/entity/segment.pyi +38 -0
- audex/entity/session.py +133 -0
- audex/entity/session.pyi +47 -0
- audex/entity/utterance.py +142 -0
- audex/entity/utterance.pyi +48 -0
- audex/entity/vp.py +68 -0
- audex/entity/vp.pyi +35 -0
- audex/exceptions.py +157 -0
- audex/filters/__init__.py +692 -0
- audex/filters/generated/__init__.py +21 -0
- audex/filters/generated/doctor.py +987 -0
- audex/filters/generated/segment.py +723 -0
- audex/filters/generated/session.py +978 -0
- audex/filters/generated/utterance.py +939 -0
- audex/filters/generated/vp.py +815 -0
- audex/helper/__init__.py +1 -0
- audex/helper/hash.py +33 -0
- audex/helper/mixin.py +65 -0
- audex/helper/net.py +19 -0
- audex/helper/settings/__init__.py +830 -0
- audex/helper/settings/fields.py +317 -0
- audex/helper/stream.py +153 -0
- audex/injectors/__init__.py +1 -0
- audex/injectors/config.py +12 -0
- audex/injectors/lifespan.py +7 -0
- audex/lib/__init__.py +1 -0
- audex/lib/cache/__init__.py +383 -0
- audex/lib/cache/inmemory.py +513 -0
- audex/lib/database/__init__.py +83 -0
- audex/lib/database/sqlite.py +406 -0
- audex/lib/exporter.py +189 -0
- audex/lib/injectors/__init__.py +1 -0
- audex/lib/injectors/cache.py +25 -0
- audex/lib/injectors/container.py +47 -0
- audex/lib/injectors/exporter.py +26 -0
- audex/lib/injectors/recorder.py +33 -0
- audex/lib/injectors/server.py +17 -0
- audex/lib/injectors/session.py +18 -0
- audex/lib/injectors/sqlite.py +24 -0
- audex/lib/injectors/store.py +13 -0
- audex/lib/injectors/transcription.py +42 -0
- audex/lib/injectors/usb.py +12 -0
- audex/lib/injectors/vpr.py +65 -0
- audex/lib/injectors/wifi.py +7 -0
- audex/lib/recorder.py +844 -0
- audex/lib/repos/__init__.py +149 -0
- audex/lib/repos/container.py +23 -0
- audex/lib/repos/database/__init__.py +1 -0
- audex/lib/repos/database/sqlite.py +672 -0
- audex/lib/repos/decorators.py +74 -0
- audex/lib/repos/doctor.py +286 -0
- audex/lib/repos/segment.py +302 -0
- audex/lib/repos/session.py +285 -0
- audex/lib/repos/tables/__init__.py +70 -0
- audex/lib/repos/tables/doctor.py +137 -0
- audex/lib/repos/tables/segment.py +113 -0
- audex/lib/repos/tables/session.py +140 -0
- audex/lib/repos/tables/utterance.py +131 -0
- audex/lib/repos/tables/vp.py +102 -0
- audex/lib/repos/utterance.py +288 -0
- audex/lib/repos/vp.py +286 -0
- audex/lib/restful.py +251 -0
- audex/lib/server/__init__.py +97 -0
- audex/lib/server/auth.py +98 -0
- audex/lib/server/handlers.py +248 -0
- audex/lib/server/templates/index.html.j2 +226 -0
- audex/lib/server/templates/login.html.j2 +111 -0
- audex/lib/server/templates/static/script.js +68 -0
- audex/lib/server/templates/static/style.css +579 -0
- audex/lib/server/types.py +123 -0
- audex/lib/session.py +503 -0
- audex/lib/store/__init__.py +238 -0
- audex/lib/store/localfile.py +411 -0
- audex/lib/transcription/__init__.py +33 -0
- audex/lib/transcription/dashscope.py +525 -0
- audex/lib/transcription/events.py +62 -0
- audex/lib/usb.py +554 -0
- audex/lib/vpr/__init__.py +38 -0
- audex/lib/vpr/unisound/__init__.py +185 -0
- audex/lib/vpr/unisound/types.py +469 -0
- audex/lib/vpr/xfyun/__init__.py +483 -0
- audex/lib/vpr/xfyun/types.py +679 -0
- audex/lib/websocket/__init__.py +8 -0
- audex/lib/websocket/connection.py +485 -0
- audex/lib/websocket/pool.py +991 -0
- audex/lib/wifi.py +1146 -0
- audex/lifespan.py +75 -0
- audex/service/__init__.py +27 -0
- audex/service/decorators.py +73 -0
- audex/service/doctor/__init__.py +652 -0
- audex/service/doctor/const.py +36 -0
- audex/service/doctor/exceptions.py +96 -0
- audex/service/doctor/types.py +54 -0
- audex/service/export/__init__.py +236 -0
- audex/service/export/const.py +17 -0
- audex/service/export/exceptions.py +34 -0
- audex/service/export/types.py +21 -0
- audex/service/injectors/__init__.py +1 -0
- audex/service/injectors/container.py +53 -0
- audex/service/injectors/doctor.py +34 -0
- audex/service/injectors/export.py +27 -0
- audex/service/injectors/session.py +49 -0
- audex/service/session/__init__.py +754 -0
- audex/service/session/const.py +34 -0
- audex/service/session/exceptions.py +67 -0
- audex/service/session/types.py +91 -0
- audex/types.py +39 -0
- audex/utils.py +287 -0
- audex/valueobj/__init__.py +81 -0
- audex/valueobj/common/__init__.py +1 -0
- audex/valueobj/common/auth.py +84 -0
- audex/valueobj/common/email.py +16 -0
- audex/valueobj/common/ops.py +22 -0
- audex/valueobj/common/phone.py +84 -0
- audex/valueobj/common/version.py +72 -0
- audex/valueobj/session.py +19 -0
- audex/valueobj/utterance.py +15 -0
- audex/view/__init__.py +51 -0
- audex/view/container.py +17 -0
- audex/view/decorators.py +303 -0
- audex/view/pages/__init__.py +1 -0
- audex/view/pages/dashboard/__init__.py +286 -0
- audex/view/pages/dashboard/wifi.py +407 -0
- audex/view/pages/login.py +110 -0
- audex/view/pages/recording.py +348 -0
- audex/view/pages/register.py +202 -0
- audex/view/pages/sessions/__init__.py +196 -0
- audex/view/pages/sessions/details.py +224 -0
- audex/view/pages/sessions/export.py +443 -0
- audex/view/pages/settings.py +374 -0
- audex/view/pages/voiceprint/__init__.py +1 -0
- audex/view/pages/voiceprint/enroll.py +195 -0
- audex/view/pages/voiceprint/update.py +195 -0
- audex/view/static/css/dashboard.css +452 -0
- audex/view/static/css/glass.css +22 -0
- audex/view/static/css/global.css +541 -0
- audex/view/static/css/login.css +386 -0
- audex/view/static/css/recording.css +439 -0
- audex/view/static/css/register.css +293 -0
- audex/view/static/css/sessions/styles.css +501 -0
- audex/view/static/css/settings.css +186 -0
- audex/view/static/css/voiceprint/enroll.css +43 -0
- audex/view/static/css/voiceprint/styles.css +209 -0
- audex/view/static/css/voiceprint/update.css +44 -0
- audex/view/static/images/logo.svg +95 -0
- audex/view/static/js/recording.js +42 -0
- audex-1.0.7a3.dist-info/METADATA +361 -0
- audex-1.0.7a3.dist-info/RECORD +192 -0
- audex-1.0.7a3.dist-info/WHEEL +4 -0
- audex-1.0.7a3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio as aio
|
|
4
|
+
import collections
|
|
5
|
+
import contextlib
|
|
6
|
+
import time
|
|
7
|
+
import types
|
|
8
|
+
import typing as t
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
import tenacity
|
|
12
|
+
|
|
13
|
+
from audex.helper.mixin import LoggingMixin
|
|
14
|
+
from audex.lib.websocket import WebsocketError
|
|
15
|
+
from audex.lib.websocket.connection import ConnectionBusyError
|
|
16
|
+
from audex.lib.websocket.connection import ConnectionClosedError
|
|
17
|
+
from audex.lib.websocket.connection import ConnectionUnavailableError
|
|
18
|
+
from audex.lib.websocket.connection import DrainConditionCallback
|
|
19
|
+
from audex.lib.websocket.connection import WebsocketConnection
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConnectionPoolExhaustedError(WebsocketError):
|
|
23
|
+
"""Raised when the connection pool has reached its maximum
|
|
24
|
+
capacity."""
|
|
25
|
+
|
|
26
|
+
default_message = "Connection pool exhausted."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConnectionPoolUnavailableError(WebsocketError):
|
|
30
|
+
"""Raised when attempting to use a closed or unavailable pool."""
|
|
31
|
+
|
|
32
|
+
default_message = "Connection pool is unavailable."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PendingConnection:
|
|
36
|
+
"""Represents a connection that is being drained before pool return.
|
|
37
|
+
|
|
38
|
+
A pending connection is in the process of draining server-sent data
|
|
39
|
+
before being returned to the available connection pool.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
connection: The WebsocketConnection being drained.
|
|
43
|
+
drain_timeout: Maximum time to spend draining in seconds.
|
|
44
|
+
drain_condition: Callback to determine what data should be drained.
|
|
45
|
+
created_at: Unix timestamp when this pending connection was created.
|
|
46
|
+
drain_task: The asyncio Task performing the drain operation.
|
|
47
|
+
pending_id: Unique identifier for this pending connection.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__logtag__ = "websocket.pool"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
connection: WebsocketConnection,
|
|
55
|
+
drain_timeout: float,
|
|
56
|
+
drain_condition: DrainConditionCallback,
|
|
57
|
+
):
|
|
58
|
+
"""Initialize a pending connection.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
connection: The WebsocketConnection to drain.
|
|
62
|
+
drain_timeout: Maximum time to spend draining in seconds.
|
|
63
|
+
drain_condition: Function to determine what constitutes server data.
|
|
64
|
+
"""
|
|
65
|
+
self.connection = connection
|
|
66
|
+
self.drain_timeout = drain_timeout
|
|
67
|
+
self.drain_condition = drain_condition
|
|
68
|
+
self.created_at = time.time()
|
|
69
|
+
self.drain_task: aio.Task[None] | None = None
|
|
70
|
+
self.pending_id = uuid.uuid4().hex
|
|
71
|
+
self._completed = False
|
|
72
|
+
self._lock = aio.Lock()
|
|
73
|
+
|
|
74
|
+
async def mark_completed(self) -> bool:
|
|
75
|
+
"""Atomically mark this pending connection as completed.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if this call marked it as completed, False if already completed.
|
|
79
|
+
"""
|
|
80
|
+
async with self._lock:
|
|
81
|
+
if self._completed:
|
|
82
|
+
return False
|
|
83
|
+
self._completed = True
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"PendingConnection({self.pending_id}, {self.connection})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class WebsocketConnectionPool(LoggingMixin):
|
|
91
|
+
"""An asynchronous WebSocket connection pool for managing reusable
|
|
92
|
+
connections.
|
|
93
|
+
|
|
94
|
+
This class provides efficient and fault-tolerant management of a pool of
|
|
95
|
+
WebSocket connections to a specified URI. It supports connection reuse,
|
|
96
|
+
automatic cleanup of idle or disconnected connections, retry mechanisms,
|
|
97
|
+
optional warm-up on startup, and server data detection during connection
|
|
98
|
+
release.
|
|
99
|
+
|
|
100
|
+
Key Features:
|
|
101
|
+
- Connection reuse for improved performance
|
|
102
|
+
- Connection acquisition with automatic exponential backoff retries
|
|
103
|
+
- Optional warm-up of initial connections on startup
|
|
104
|
+
- Background cleanup of idle or disconnected connections
|
|
105
|
+
- Asynchronous server data draining to prevent data loss
|
|
106
|
+
- Supports context management for automatic connection release
|
|
107
|
+
- Thread-safe operations with proper lock management
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
uri: The WebSocket URI to connect to.
|
|
111
|
+
headers: Optional headers sent with each connection.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
__logtag__ = "audex.lib.websocket.pool"
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
uri: str,
|
|
120
|
+
headers: dict[str, str] | None = None,
|
|
121
|
+
idle_timeout: float = 60.0,
|
|
122
|
+
max_connections: int = 50,
|
|
123
|
+
max_retries: int = 3,
|
|
124
|
+
cleanup_interval: float = 5.0,
|
|
125
|
+
connection_timeout: float = 10.0,
|
|
126
|
+
warmup_connections: int = 0,
|
|
127
|
+
check_server_data_on_release: bool = False,
|
|
128
|
+
drain_timeout: float = 10.0,
|
|
129
|
+
drain_quiet_period: float = 2.0,
|
|
130
|
+
drain_condition: DrainConditionCallback | None = None,
|
|
131
|
+
**kwargs: t.Any,
|
|
132
|
+
):
|
|
133
|
+
"""Initialize a WebSocket connection pool.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
uri: The WebSocket URI to connect to.
|
|
137
|
+
headers: Optional HTTP headers to include in connection requests.
|
|
138
|
+
idle_timeout: Maximum time in seconds a connection can remain idle
|
|
139
|
+
before being closed. Defaults to 60.0.
|
|
140
|
+
max_connections: Maximum number of concurrent connections allowed
|
|
141
|
+
in the pool. Defaults to 50.
|
|
142
|
+
max_retries: Maximum number of retry attempts when acquiring a
|
|
143
|
+
connection fails. Defaults to 3.
|
|
144
|
+
cleanup_interval: Interval in seconds between cleanup operations
|
|
145
|
+
for idle connections. Defaults to 5.0.
|
|
146
|
+
connection_timeout: Timeout in seconds for establishing new
|
|
147
|
+
connections. Defaults to 10.0.
|
|
148
|
+
warmup_connections: Number of connections to create during pool
|
|
149
|
+
initialization. Defaults to 0.
|
|
150
|
+
check_server_data_on_release: Whether to check for server data
|
|
151
|
+
during connection release. Defaults to False.
|
|
152
|
+
drain_timeout: Maximum time in seconds to drain server data
|
|
153
|
+
during release. Defaults to 10.0.
|
|
154
|
+
drain_quiet_period: Time in seconds to wait without receiving data
|
|
155
|
+
before considering connection clean. Defaults to 2.0.
|
|
156
|
+
drain_condition: Function to determine what constitutes server data
|
|
157
|
+
that should be drained. Defaults to None (uses default
|
|
158
|
+
condition).
|
|
159
|
+
**kwargs: Additional parameters to pass to WebsocketConnection.
|
|
160
|
+
"""
|
|
161
|
+
super().__init__()
|
|
162
|
+
self.uri = uri
|
|
163
|
+
self.headers = headers or {}
|
|
164
|
+
self._idle_timeout = idle_timeout
|
|
165
|
+
self._max_connections = max_connections
|
|
166
|
+
self._max_retries = max_retries
|
|
167
|
+
self._cleanup_interval = cleanup_interval
|
|
168
|
+
self._connection_timeout = connection_timeout
|
|
169
|
+
self._warmup_connections = warmup_connections
|
|
170
|
+
self._check_server_data_on_release = check_server_data_on_release
|
|
171
|
+
self._drain_timeout = drain_timeout
|
|
172
|
+
self._drain_quiet_period = drain_quiet_period
|
|
173
|
+
self._drain_condition = drain_condition or self._default_drain_condition
|
|
174
|
+
self._params = kwargs
|
|
175
|
+
|
|
176
|
+
self._available: collections.deque[WebsocketConnection] = collections.deque()
|
|
177
|
+
self._busy: set[WebsocketConnection] = set()
|
|
178
|
+
# pending_id -> PendingConnection
|
|
179
|
+
self._pending: dict[str, PendingConnection] = {}
|
|
180
|
+
self._total = 0
|
|
181
|
+
self._lock = aio.Lock()
|
|
182
|
+
self._closed = False
|
|
183
|
+
self._cleanup_task: aio.Task[None] | None = None
|
|
184
|
+
self._started = False
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _default_drain_condition(message: str | bytes) -> bool:
|
|
188
|
+
"""Default condition to determine if incoming data should be
|
|
189
|
+
drained.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
message: The incoming message from the server.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if the message should be drained (considered as server data).
|
|
196
|
+
"""
|
|
197
|
+
# By default, consider any non-empty message as server data
|
|
198
|
+
return len(message) > 0
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def is_closed(self) -> bool:
|
|
202
|
+
"""Check if the connection pool is closed.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if the pool is closed, False otherwise.
|
|
206
|
+
"""
|
|
207
|
+
return self._closed
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def is_started(self) -> bool:
|
|
211
|
+
"""Check if the connection pool has been started.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True if the pool has been started, False otherwise.
|
|
215
|
+
"""
|
|
216
|
+
return self._started
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def total_connections(self) -> int:
|
|
220
|
+
"""Get the total number of connections in the pool.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The total number of connections (available, busy, and pending).
|
|
224
|
+
"""
|
|
225
|
+
return self._total
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def available_connections(self) -> int:
|
|
229
|
+
"""Get the number of available connections.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
The number of connections available for use.
|
|
233
|
+
"""
|
|
234
|
+
return len(self._available)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def busy_connections(self) -> int:
|
|
238
|
+
"""Get the number of busy connections.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The number of connections currently in use.
|
|
242
|
+
"""
|
|
243
|
+
return len(self._busy)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def pending_connections(self) -> int:
|
|
247
|
+
"""Get the number of pending connections.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The number of connections being drained.
|
|
251
|
+
"""
|
|
252
|
+
return len(self._pending)
|
|
253
|
+
|
|
254
|
+
def _create_connection(self) -> WebsocketConnection:
|
|
255
|
+
"""Create a new WebsocketConnection with pool configuration.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
A new WebsocketConnection instance configured with pool parameters.
|
|
259
|
+
"""
|
|
260
|
+
return WebsocketConnection(
|
|
261
|
+
uri=self.uri,
|
|
262
|
+
headers=self.headers,
|
|
263
|
+
idle_timeout=self._idle_timeout,
|
|
264
|
+
check_server_data_on_release=self._check_server_data_on_release,
|
|
265
|
+
drain_timeout=self._drain_timeout,
|
|
266
|
+
drain_condition=self._drain_condition,
|
|
267
|
+
**self._params,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def start(self) -> None:
|
|
271
|
+
"""Start the connection pool.
|
|
272
|
+
|
|
273
|
+
Initializes the pool, starts the cleanup task, and optionally
|
|
274
|
+
creates warmup connections if configured.
|
|
275
|
+
"""
|
|
276
|
+
if self._started or self._closed:
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
self._started = True
|
|
280
|
+
if self._cleanup_task is None or self._cleanup_task.done():
|
|
281
|
+
self._cleanup_task = aio.create_task(self._cleanup_loop())
|
|
282
|
+
|
|
283
|
+
# Warmup connections if specified
|
|
284
|
+
if self._warmup_connections > 0:
|
|
285
|
+
await self._warmup_pool()
|
|
286
|
+
|
|
287
|
+
self.logger.info(f"Started WebSocket connection pool for {self.uri}")
|
|
288
|
+
|
|
289
|
+
async def _warmup_pool(self) -> None:
|
|
290
|
+
"""Create and connect initial connections for the pool.
|
|
291
|
+
|
|
292
|
+
Creates up to warmup_connections or max_connections (whichever
|
|
293
|
+
is smaller) and adds them to the available connection pool.
|
|
294
|
+
"""
|
|
295
|
+
warmup_count = min(self._warmup_connections, self._max_connections)
|
|
296
|
+
|
|
297
|
+
for _ in range(warmup_count):
|
|
298
|
+
try:
|
|
299
|
+
connection = self._create_connection()
|
|
300
|
+
await aio.wait_for(connection.connect(), timeout=self._connection_timeout)
|
|
301
|
+
|
|
302
|
+
async with self._lock:
|
|
303
|
+
self._available.append(connection)
|
|
304
|
+
self._total += 1
|
|
305
|
+
|
|
306
|
+
self.logger.debug(f"Warmed up connection: {connection}")
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
self.logger.warning(f"Failed to warm up connection: {e}")
|
|
310
|
+
# Continue warming up remaining connections even if one fails
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
self.logger.info(f"Warmed up {len(self._available)} connections")
|
|
314
|
+
|
|
315
|
+
async def acquire(self) -> WebsocketConnection:
|
|
316
|
+
"""Acquire a connection from the pool.
|
|
317
|
+
|
|
318
|
+
Attempts to reuse an existing available connection, or creates a new
|
|
319
|
+
one if none are available and the pool limit hasn't been reached.
|
|
320
|
+
Includes automatic retry logic for transient failures.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
An acquired WebsocketConnection ready for use.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ConnectionPoolUnavailableError: If the pool is closed.
|
|
327
|
+
ConnectionPoolExhaustedError: If maximum connections limit is
|
|
328
|
+
reached.
|
|
329
|
+
ConnectionUnavailableError: If connection acquisition fails
|
|
330
|
+
after retries.
|
|
331
|
+
"""
|
|
332
|
+
if not self._started:
|
|
333
|
+
await self.start()
|
|
334
|
+
|
|
335
|
+
retry = tenacity.retry(
|
|
336
|
+
stop=tenacity.stop_after_attempt(self._max_retries),
|
|
337
|
+
wait=tenacity.wait_exponential(multiplier=1, max=10),
|
|
338
|
+
retry=tenacity.retry_if_exception_type((
|
|
339
|
+
ConnectionUnavailableError,
|
|
340
|
+
ConnectionClosedError,
|
|
341
|
+
OSError,
|
|
342
|
+
aio.TimeoutError,
|
|
343
|
+
)),
|
|
344
|
+
)
|
|
345
|
+
return await retry(self._acquire)()
|
|
346
|
+
|
|
347
|
+
async def _acquire(self) -> WebsocketConnection: # type: ignore
|
|
348
|
+
"""Internal method to acquire a connection from the pool.
|
|
349
|
+
|
|
350
|
+
This method attempts to reuse existing connections or create new ones.
|
|
351
|
+
Health checks are performed outside of locks to avoid blocking.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
An acquired WebsocketConnection.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ConnectionPoolUnavailableError: If the pool is closed.
|
|
358
|
+
ConnectionPoolExhaustedError: If the pool is at capacity.
|
|
359
|
+
ConnectionUnavailableError: If connection cannot be established.
|
|
360
|
+
"""
|
|
361
|
+
while True:
|
|
362
|
+
connection_to_test = None
|
|
363
|
+
|
|
364
|
+
async with self._lock:
|
|
365
|
+
if self._closed:
|
|
366
|
+
raise ConnectionPoolUnavailableError("Connection pool is closed")
|
|
367
|
+
|
|
368
|
+
# Try to reuse existing available connection
|
|
369
|
+
if self._available:
|
|
370
|
+
connection_to_test = self._available.popleft()
|
|
371
|
+
|
|
372
|
+
# Test connection outside lock if we got one
|
|
373
|
+
if connection_to_test is not None:
|
|
374
|
+
if connection_to_test.is_connected and not connection_to_test._closed:
|
|
375
|
+
try:
|
|
376
|
+
await connection_to_test.acquire()
|
|
377
|
+
|
|
378
|
+
# Health check the connection (outside lock)
|
|
379
|
+
try:
|
|
380
|
+
await connection_to_test.ping()
|
|
381
|
+
|
|
382
|
+
async with self._lock:
|
|
383
|
+
self._busy.add(connection_to_test)
|
|
384
|
+
|
|
385
|
+
self.logger.debug(f"Reused connection from pool: {connection_to_test}")
|
|
386
|
+
return connection_to_test
|
|
387
|
+
|
|
388
|
+
except (ConnectionClosedError, ConnectionUnavailableError):
|
|
389
|
+
await connection_to_test.release()
|
|
390
|
+
await self._remove_connection(connection_to_test)
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
except (ConnectionBusyError, ConnectionClosedError, ConnectionUnavailableError):
|
|
394
|
+
# Connection is no longer usable
|
|
395
|
+
await self._remove_connection(connection_to_test)
|
|
396
|
+
continue
|
|
397
|
+
else:
|
|
398
|
+
# Connection is not healthy
|
|
399
|
+
await self._remove_connection(connection_to_test)
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
# No available connection, need to create new one
|
|
403
|
+
async with self._lock:
|
|
404
|
+
if self._closed:
|
|
405
|
+
raise ConnectionPoolUnavailableError("Connection pool is closed")
|
|
406
|
+
|
|
407
|
+
# Double-check we're still under limit
|
|
408
|
+
if self._total >= self._max_connections:
|
|
409
|
+
raise ConnectionPoolExhaustedError(
|
|
410
|
+
f"Maximum connections {self._max_connections} reached. "
|
|
411
|
+
f"Available: {len(self._available)}, Busy: "
|
|
412
|
+
f"{len(self._busy)}, Pending: {len(self._pending)}"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Reserve a slot for new connection
|
|
416
|
+
self._total += 1
|
|
417
|
+
|
|
418
|
+
# Create and connect outside lock
|
|
419
|
+
connection = self._create_connection()
|
|
420
|
+
creation_success = False
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
await aio.wait_for(connection.connect(), timeout=self._connection_timeout)
|
|
424
|
+
await connection.acquire()
|
|
425
|
+
|
|
426
|
+
async with self._lock:
|
|
427
|
+
self._busy.add(connection)
|
|
428
|
+
|
|
429
|
+
creation_success = True
|
|
430
|
+
self.logger.debug(f"Created new connection: {connection}")
|
|
431
|
+
return connection
|
|
432
|
+
|
|
433
|
+
except Exception as e:
|
|
434
|
+
self.logger.error(f"Failed to create connection: {e}")
|
|
435
|
+
|
|
436
|
+
if not creation_success:
|
|
437
|
+
# Release the reserved slot if creation failed
|
|
438
|
+
async with self._lock:
|
|
439
|
+
self._total -= 1
|
|
440
|
+
|
|
441
|
+
await connection.close()
|
|
442
|
+
raise ConnectionUnavailableError(f"Failed to create connection: {e}") from e
|
|
443
|
+
|
|
444
|
+
async def acquire_new(self) -> WebsocketConnection:
|
|
445
|
+
"""Force acquire a new connection, avoiding reuse of existing
|
|
446
|
+
ones.
|
|
447
|
+
|
|
448
|
+
This method always creates a fresh connection and returns it in
|
|
449
|
+
acquired state, similar to acquire() but without attempting to
|
|
450
|
+
reuse existing connections.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
A newly created and acquired WebsocketConnection.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
ConnectionPoolUnavailableError: If the pool is closed.
|
|
457
|
+
ConnectionPoolExhaustedError: If maximum connections limit is
|
|
458
|
+
reached.
|
|
459
|
+
ConnectionUnavailableError: If connection creation fails after
|
|
460
|
+
retries.
|
|
461
|
+
"""
|
|
462
|
+
if not self._started:
|
|
463
|
+
await self.start()
|
|
464
|
+
|
|
465
|
+
retry = tenacity.retry(
|
|
466
|
+
stop=tenacity.stop_after_attempt(self._max_retries),
|
|
467
|
+
wait=tenacity.wait_exponential(multiplier=1, max=10),
|
|
468
|
+
retry=tenacity.retry_if_exception_type((
|
|
469
|
+
ConnectionUnavailableError,
|
|
470
|
+
ConnectionClosedError,
|
|
471
|
+
OSError,
|
|
472
|
+
aio.TimeoutError,
|
|
473
|
+
)),
|
|
474
|
+
)
|
|
475
|
+
return await retry(self._acquire_new)()
|
|
476
|
+
|
|
477
|
+
async def _acquire_new(self) -> WebsocketConnection:
|
|
478
|
+
"""Internal method to force create a new connection.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
A newly created and acquired WebsocketConnection.
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
ConnectionPoolUnavailableError: If the pool is closed.
|
|
485
|
+
ConnectionPoolExhaustedError: If the pool is at capacity.
|
|
486
|
+
ConnectionUnavailableError: If connection cannot be established.
|
|
487
|
+
"""
|
|
488
|
+
async with self._lock:
|
|
489
|
+
if self._closed:
|
|
490
|
+
raise ConnectionPoolUnavailableError("Connection pool is closed")
|
|
491
|
+
|
|
492
|
+
# Check if we can create a new connection
|
|
493
|
+
if self._total >= self._max_connections:
|
|
494
|
+
raise ConnectionPoolExhaustedError(
|
|
495
|
+
f"Maximum connections {self._max_connections} reached. "
|
|
496
|
+
f"Available: {len(self._available)}, Busy: "
|
|
497
|
+
f"{len(self._busy)}, Pending: {len(self._pending)}"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Reserve a slot
|
|
501
|
+
self._total += 1
|
|
502
|
+
|
|
503
|
+
# Create and connect outside lock
|
|
504
|
+
connection = self._create_connection()
|
|
505
|
+
creation_success = False
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
await aio.wait_for(connection.connect(), timeout=self._connection_timeout)
|
|
509
|
+
await connection.acquire()
|
|
510
|
+
|
|
511
|
+
async with self._lock:
|
|
512
|
+
self._busy.add(connection)
|
|
513
|
+
|
|
514
|
+
creation_success = True
|
|
515
|
+
self.logger.debug(f"Force created new connection: {connection}")
|
|
516
|
+
return connection
|
|
517
|
+
|
|
518
|
+
except Exception as e:
|
|
519
|
+
self.logger.error(f"Failed to force create connection: {e}")
|
|
520
|
+
|
|
521
|
+
if not creation_success:
|
|
522
|
+
# Release the reserved slot
|
|
523
|
+
async with self._lock:
|
|
524
|
+
self._total -= 1
|
|
525
|
+
|
|
526
|
+
await connection.close()
|
|
527
|
+
raise ConnectionUnavailableError(f"Failed to create connection: {e}") from e
|
|
528
|
+
|
|
529
|
+
async def _remove_connection(self, connection: WebsocketConnection) -> None:
|
|
530
|
+
"""Remove and close a connection, updating counters atomically.
|
|
531
|
+
|
|
532
|
+
This method ensures that a connection is removed from all tracking
|
|
533
|
+
structures exactly once and the total counter is decremented correctly.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
connection: The WebsocketConnection to remove.
|
|
537
|
+
"""
|
|
538
|
+
async with self._lock:
|
|
539
|
+
was_tracked = False
|
|
540
|
+
|
|
541
|
+
# Remove from busy set
|
|
542
|
+
if connection in self._busy:
|
|
543
|
+
self._busy.discard(connection)
|
|
544
|
+
was_tracked = True
|
|
545
|
+
|
|
546
|
+
# Remove from available deque
|
|
547
|
+
try:
|
|
548
|
+
self._available.remove(connection)
|
|
549
|
+
was_tracked = True
|
|
550
|
+
except ValueError:
|
|
551
|
+
pass # Not in available
|
|
552
|
+
|
|
553
|
+
# Remove from pending connections
|
|
554
|
+
pending_to_remove = []
|
|
555
|
+
for pending_id, pending_conn in self._pending.items():
|
|
556
|
+
if pending_conn.connection == connection:
|
|
557
|
+
pending_to_remove.append(pending_id)
|
|
558
|
+
was_tracked = True
|
|
559
|
+
|
|
560
|
+
for pending_id in pending_to_remove:
|
|
561
|
+
pending_conn = self._pending.pop(pending_id)
|
|
562
|
+
if pending_conn.drain_task and not pending_conn.drain_task.done():
|
|
563
|
+
pending_conn.drain_task.cancel()
|
|
564
|
+
|
|
565
|
+
# Only decrement counter if we actually tracked this connection
|
|
566
|
+
if was_tracked:
|
|
567
|
+
self._total -= 1
|
|
568
|
+
|
|
569
|
+
# Close connection outside lock
|
|
570
|
+
try:
|
|
571
|
+
await connection.close()
|
|
572
|
+
except Exception as e:
|
|
573
|
+
self.logger.warning(f"Error closing connection during removal: {e}")
|
|
574
|
+
|
|
575
|
+
async def _drain_connection(self, pending_conn: PendingConnection) -> None:
|
|
576
|
+
"""Background task to drain server data from a connection.
|
|
577
|
+
|
|
578
|
+
This task continuously receives data from the websocket until either:
|
|
579
|
+
1. No data is received for drain_quiet_period seconds
|
|
580
|
+
2. The drain_timeout is reached
|
|
581
|
+
3. An error occurs
|
|
582
|
+
|
|
583
|
+
If the connection is successfully drained, it's moved to available pool.
|
|
584
|
+
Otherwise, it's removed from the pool.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
pending_conn: The PendingConnection to drain.
|
|
588
|
+
"""
|
|
589
|
+
connection = pending_conn.connection
|
|
590
|
+
start_time = time.time()
|
|
591
|
+
last_message_time = start_time
|
|
592
|
+
drained_messages = 0
|
|
593
|
+
|
|
594
|
+
self.logger.debug(f"Starting drain task for connection {connection}")
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
while True:
|
|
598
|
+
current_time = time.time()
|
|
599
|
+
|
|
600
|
+
# Check if we've exceeded the overall drain timeout
|
|
601
|
+
if current_time - start_time > pending_conn.drain_timeout:
|
|
602
|
+
self.logger.debug(f"Drain timeout reached for connection {connection}")
|
|
603
|
+
break
|
|
604
|
+
|
|
605
|
+
# Check if we've been quiet for the required period
|
|
606
|
+
if current_time - last_message_time > self._drain_quiet_period:
|
|
607
|
+
self.logger.debug(
|
|
608
|
+
f"Quiet period reached for connection {connection}, moving to available"
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Mark as completed atomically
|
|
612
|
+
if not await pending_conn.mark_completed():
|
|
613
|
+
# Already completed by cleanup
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
# Connection is clean, move it to available
|
|
617
|
+
async with self._lock:
|
|
618
|
+
if pending_conn.pending_id in self._pending:
|
|
619
|
+
self._pending.pop(pending_conn.pending_id)
|
|
620
|
+
if connection.is_connected and not connection._closed:
|
|
621
|
+
self._available.append(connection)
|
|
622
|
+
self.logger.debug(
|
|
623
|
+
f"Moved drained connection to available: {connection}"
|
|
624
|
+
)
|
|
625
|
+
else:
|
|
626
|
+
self.logger.debug(
|
|
627
|
+
f"Connection not connected during drain "
|
|
628
|
+
f"completion: {connection}"
|
|
629
|
+
)
|
|
630
|
+
# Don't call _remove_connection here as we already popped from pending
|
|
631
|
+
self._total -= 1
|
|
632
|
+
await connection.close()
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# Try to receive data with a short timeout
|
|
636
|
+
try:
|
|
637
|
+
recv_timeout = min(0.5, self._drain_quiet_period / 2)
|
|
638
|
+
message = await aio.wait_for(connection.websocket.recv(), timeout=recv_timeout) # type: ignore
|
|
639
|
+
|
|
640
|
+
last_message_time = current_time
|
|
641
|
+
drained_messages += 1
|
|
642
|
+
|
|
643
|
+
# Check if this message matches our drain condition
|
|
644
|
+
if pending_conn.drain_condition(message):
|
|
645
|
+
self.logger.debug(
|
|
646
|
+
f"Drained server message from {connection}: {str(message)[:100]}..."
|
|
647
|
+
)
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
# Message doesn't match drain condition, connection not clean
|
|
651
|
+
self.logger.debug(
|
|
652
|
+
f"Received unexpected message during drain from "
|
|
653
|
+
f"{connection}: {str(message)[:100]}..."
|
|
654
|
+
)
|
|
655
|
+
break
|
|
656
|
+
|
|
657
|
+
except TimeoutError:
|
|
658
|
+
# No data received within timeout, continue checking
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
except Exception as e:
|
|
662
|
+
self.logger.debug(f"Error during drain process for {connection}: {e}")
|
|
663
|
+
break
|
|
664
|
+
|
|
665
|
+
except Exception as e:
|
|
666
|
+
self.logger.debug(f"Unexpected error in drain task for {connection}: {e}")
|
|
667
|
+
|
|
668
|
+
finally:
|
|
669
|
+
# Mark as completed atomically
|
|
670
|
+
if not await pending_conn.mark_completed():
|
|
671
|
+
# Already completed by cleanup
|
|
672
|
+
return # noqa: B012
|
|
673
|
+
|
|
674
|
+
# Connection is not clean or an error occurred, remove it
|
|
675
|
+
self.logger.debug(
|
|
676
|
+
f"Removing connection after drain task: {connection}, "
|
|
677
|
+
f"messages drained: {drained_messages}"
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
async with self._lock:
|
|
681
|
+
if pending_conn.pending_id in self._pending:
|
|
682
|
+
self._pending.pop(pending_conn.pending_id)
|
|
683
|
+
self._total -= 1
|
|
684
|
+
|
|
685
|
+
await connection.close()
|
|
686
|
+
|
|
687
|
+
async def release(self, connection: WebsocketConnection, *, force_remove: bool = False) -> None:
|
|
688
|
+
"""Release a connection back to the pool.
|
|
689
|
+
|
|
690
|
+
The connection will be either:
|
|
691
|
+
1. Returned to the available pool immediately if data draining is disabled
|
|
692
|
+
2. Put into pending state for background draining if data draining is enabled
|
|
693
|
+
3. Removed from the pool if force_remove is True or the connection is unhealthy
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
connection: The WebsocketConnection to release.
|
|
697
|
+
force_remove: If True, the connection will be removed from the pool
|
|
698
|
+
regardless of its state. Defaults to False.
|
|
699
|
+
"""
|
|
700
|
+
async with self._lock:
|
|
701
|
+
if connection not in self._busy:
|
|
702
|
+
self.logger.warning(
|
|
703
|
+
f"Attempting to release connection not in busy set: {connection}"
|
|
704
|
+
)
|
|
705
|
+
return
|
|
706
|
+
|
|
707
|
+
self._busy.remove(connection)
|
|
708
|
+
|
|
709
|
+
# Release connection outside lock
|
|
710
|
+
try:
|
|
711
|
+
await connection.release()
|
|
712
|
+
except Exception as e:
|
|
713
|
+
self.logger.warning(f"Error releasing connection: {e}")
|
|
714
|
+
await self._remove_connection(connection)
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
async with self._lock:
|
|
718
|
+
if force_remove or not connection.is_connected or self._closed:
|
|
719
|
+
# Connection should be removed
|
|
720
|
+
if connection.is_connected:
|
|
721
|
+
self._total -= 1
|
|
722
|
+
await connection.close()
|
|
723
|
+
self.logger.debug(f"Removed connection from pool: {connection}")
|
|
724
|
+
return
|
|
725
|
+
|
|
726
|
+
# If we don't need to check for server data, directly return to available
|
|
727
|
+
if not self._check_server_data_on_release:
|
|
728
|
+
self._available.append(connection)
|
|
729
|
+
self.logger.debug(f"Released connection directly to available: {connection}")
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
# Put connection into pending state for background draining
|
|
733
|
+
pending_conn = PendingConnection(
|
|
734
|
+
connection=connection,
|
|
735
|
+
drain_timeout=self._drain_timeout,
|
|
736
|
+
drain_condition=self._drain_condition,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
self._pending[pending_conn.pending_id] = pending_conn
|
|
740
|
+
|
|
741
|
+
# Start background drain task outside lock
|
|
742
|
+
pending_conn.drain_task = aio.create_task(self._drain_connection(pending_conn))
|
|
743
|
+
|
|
744
|
+
self.logger.debug(f"Put connection into pending state for draining: {connection}")
|
|
745
|
+
|
|
746
|
+
@contextlib.asynccontextmanager
|
|
747
|
+
async def get_connection(self) -> t.AsyncGenerator[WebsocketConnection, None]:
|
|
748
|
+
"""Get a connection from the pool as an async context manager.
|
|
749
|
+
|
|
750
|
+
The connection will be automatically released back to the pool when
|
|
751
|
+
the context manager exits.
|
|
752
|
+
|
|
753
|
+
Yields:
|
|
754
|
+
A WebsocketConnection from the pool.
|
|
755
|
+
|
|
756
|
+
Raises:
|
|
757
|
+
ConnectionPoolUnavailableError: If the pool is closed.
|
|
758
|
+
ConnectionPoolExhaustedError: If maximum connections limit is reached.
|
|
759
|
+
ConnectionUnavailableError: If connection acquisition fails.
|
|
760
|
+
|
|
761
|
+
Example:
|
|
762
|
+
async with pool.get_connection() as conn:
|
|
763
|
+
await conn.send("Hello")
|
|
764
|
+
response = await conn.recv()
|
|
765
|
+
"""
|
|
766
|
+
connection = await self.acquire()
|
|
767
|
+
try:
|
|
768
|
+
yield connection
|
|
769
|
+
finally:
|
|
770
|
+
await self.release(connection)
|
|
771
|
+
|
|
772
|
+
@contextlib.asynccontextmanager
|
|
773
|
+
async def get_new_connection(self) -> t.AsyncGenerator[WebsocketConnection, None]:
|
|
774
|
+
"""Get a new connection from the pool as an async context
|
|
775
|
+
manager.
|
|
776
|
+
|
|
777
|
+
Forces creation of a new connection without reusing existing ones.
|
|
778
|
+
The connection will be automatically released back to the pool when
|
|
779
|
+
the context manager exits.
|
|
780
|
+
|
|
781
|
+
Yields:
|
|
782
|
+
A newly created WebsocketConnection.
|
|
783
|
+
|
|
784
|
+
Raises:
|
|
785
|
+
ConnectionPoolUnavailableError: If the pool is closed.
|
|
786
|
+
ConnectionPoolExhaustedError: If maximum connections limit is
|
|
787
|
+
reached.
|
|
788
|
+
ConnectionUnavailableError: If connection creation fails.
|
|
789
|
+
|
|
790
|
+
Example:
|
|
791
|
+
async with pool.get_new_connection() as conn:
|
|
792
|
+
await conn.send("Hello")
|
|
793
|
+
response = await conn.recv()
|
|
794
|
+
"""
|
|
795
|
+
connection = await self.acquire_new()
|
|
796
|
+
try:
|
|
797
|
+
yield connection
|
|
798
|
+
finally:
|
|
799
|
+
await self.release(connection)
|
|
800
|
+
|
|
801
|
+
async def _cleanup_loop(self) -> None:
|
|
802
|
+
"""Background task that periodically cleans up idle connections.
|
|
803
|
+
|
|
804
|
+
This task runs continuously until the pool is closed, checking
|
|
805
|
+
for and removing idle or disconnected connections at regular
|
|
806
|
+
intervals.
|
|
807
|
+
"""
|
|
808
|
+
try:
|
|
809
|
+
while not self._closed:
|
|
810
|
+
await aio.sleep(self._cleanup_interval)
|
|
811
|
+
await self._cleanup_idle()
|
|
812
|
+
except aio.CancelledError:
|
|
813
|
+
pass
|
|
814
|
+
except Exception as e:
|
|
815
|
+
self.logger.error(f"Error in cleanup loop: {e}")
|
|
816
|
+
|
|
817
|
+
async def _cleanup_idle(self) -> None:
|
|
818
|
+
"""Clean up idle and disconnected connections from the pool.
|
|
819
|
+
|
|
820
|
+
This method removes:
|
|
821
|
+
- Available connections that are idle or disconnected
|
|
822
|
+
- Busy connections that are no longer connected
|
|
823
|
+
- Pending connections that have timed out or are disconnected
|
|
824
|
+
"""
|
|
825
|
+
current_time = time.time()
|
|
826
|
+
|
|
827
|
+
# Collect connections to remove
|
|
828
|
+
available_to_remove = []
|
|
829
|
+
busy_to_remove = []
|
|
830
|
+
pending_to_remove = []
|
|
831
|
+
|
|
832
|
+
async with self._lock:
|
|
833
|
+
# Find available connections to clean up
|
|
834
|
+
for conn in list(self._available):
|
|
835
|
+
if (
|
|
836
|
+
not conn.is_connected
|
|
837
|
+
or (current_time - conn.last_activity) > self._idle_timeout
|
|
838
|
+
):
|
|
839
|
+
available_to_remove.append(conn)
|
|
840
|
+
|
|
841
|
+
# Find busy connections that are no longer connected
|
|
842
|
+
for conn in list(self._busy):
|
|
843
|
+
if not conn.is_connected:
|
|
844
|
+
busy_to_remove.append(conn)
|
|
845
|
+
|
|
846
|
+
# Find pending connections to clean up
|
|
847
|
+
for pending_id, pending_conn in list(self._pending.items()):
|
|
848
|
+
conn = pending_conn.connection
|
|
849
|
+
|
|
850
|
+
# Check if connection is no longer connected
|
|
851
|
+
if not conn.is_connected:
|
|
852
|
+
pending_to_remove.append((pending_id, pending_conn))
|
|
853
|
+
continue
|
|
854
|
+
|
|
855
|
+
# Check if pending connection has been around too long
|
|
856
|
+
if (current_time - pending_conn.created_at) > (
|
|
857
|
+
self._drain_timeout + self._cleanup_interval
|
|
858
|
+
):
|
|
859
|
+
pending_to_remove.append((pending_id, pending_conn))
|
|
860
|
+
continue
|
|
861
|
+
|
|
862
|
+
# Remove from tracking structures
|
|
863
|
+
for conn in available_to_remove:
|
|
864
|
+
try:
|
|
865
|
+
self._available.remove(conn)
|
|
866
|
+
self._total -= 1
|
|
867
|
+
except ValueError:
|
|
868
|
+
pass # Already removed
|
|
869
|
+
|
|
870
|
+
for conn in busy_to_remove:
|
|
871
|
+
self._busy.discard(conn)
|
|
872
|
+
self._total -= 1
|
|
873
|
+
|
|
874
|
+
for pending_id, pending_conn in pending_to_remove:
|
|
875
|
+
# Check if drain task already completed this
|
|
876
|
+
if await pending_conn.mark_completed() and pending_id in self._pending:
|
|
877
|
+
# We're the first to mark it completed
|
|
878
|
+
self._pending.pop(pending_id)
|
|
879
|
+
self._total -= 1
|
|
880
|
+
|
|
881
|
+
# Close connections outside lock
|
|
882
|
+
for conn in available_to_remove:
|
|
883
|
+
try:
|
|
884
|
+
await conn.close()
|
|
885
|
+
self.logger.debug(f"Cleaned up idle connection: {conn}")
|
|
886
|
+
except Exception as e:
|
|
887
|
+
self.logger.warning(f"Error closing idle connection: {e}")
|
|
888
|
+
|
|
889
|
+
for conn in busy_to_remove:
|
|
890
|
+
try:
|
|
891
|
+
await conn.close()
|
|
892
|
+
self.logger.warning(f"Cleaned up disconnected busy connection: {conn}")
|
|
893
|
+
except Exception as e:
|
|
894
|
+
self.logger.warning(f"Error closing disconnected connection: {e}")
|
|
895
|
+
|
|
896
|
+
for _, pending_conn in pending_to_remove:
|
|
897
|
+
# Cancel the drain task if it's still running
|
|
898
|
+
if pending_conn.drain_task and not pending_conn.drain_task.done():
|
|
899
|
+
pending_conn.drain_task.cancel()
|
|
900
|
+
|
|
901
|
+
try:
|
|
902
|
+
await pending_conn.connection.close()
|
|
903
|
+
self.logger.debug(
|
|
904
|
+
f"Cleaned up timed out pending connection: {pending_conn.connection}"
|
|
905
|
+
)
|
|
906
|
+
except Exception as e:
|
|
907
|
+
self.logger.warning(f"Error closing timed out pending connection: {e}")
|
|
908
|
+
|
|
909
|
+
async def close_all(self) -> None:
|
|
910
|
+
"""Close all connections and shut down the pool.
|
|
911
|
+
|
|
912
|
+
Stops the cleanup task and closes all connections in the pool.
|
|
913
|
+
After calling this method, the pool cannot be used again.
|
|
914
|
+
"""
|
|
915
|
+
async with self._lock:
|
|
916
|
+
if self._closed:
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
self._closed = True
|
|
920
|
+
|
|
921
|
+
# Cancel cleanup task
|
|
922
|
+
if self._cleanup_task and not self._cleanup_task.done():
|
|
923
|
+
self._cleanup_task.cancel()
|
|
924
|
+
|
|
925
|
+
# Collect all connections to close
|
|
926
|
+
connections_to_close = []
|
|
927
|
+
connections_to_close.extend(list(self._available))
|
|
928
|
+
connections_to_close.extend(list(self._busy))
|
|
929
|
+
|
|
930
|
+
drain_tasks_to_cancel = []
|
|
931
|
+
for pending_conn in self._pending.values():
|
|
932
|
+
connections_to_close.append(pending_conn.connection)
|
|
933
|
+
if pending_conn.drain_task and not pending_conn.drain_task.done():
|
|
934
|
+
drain_tasks_to_cancel.append(pending_conn.drain_task)
|
|
935
|
+
|
|
936
|
+
# Clear all tracking structures
|
|
937
|
+
self._available.clear()
|
|
938
|
+
self._busy.clear()
|
|
939
|
+
self._pending.clear()
|
|
940
|
+
self._total = 0
|
|
941
|
+
|
|
942
|
+
# Wait for cleanup task to finish
|
|
943
|
+
if self._cleanup_task:
|
|
944
|
+
with contextlib.suppress(aio.CancelledError):
|
|
945
|
+
await self._cleanup_task
|
|
946
|
+
self._cleanup_task = None
|
|
947
|
+
|
|
948
|
+
# Cancel all drain tasks
|
|
949
|
+
for task in drain_tasks_to_cancel:
|
|
950
|
+
task.cancel()
|
|
951
|
+
|
|
952
|
+
# Wait for all drain tasks to finish
|
|
953
|
+
if drain_tasks_to_cancel:
|
|
954
|
+
await aio.gather(*drain_tasks_to_cancel, return_exceptions=True)
|
|
955
|
+
|
|
956
|
+
# Close all connections
|
|
957
|
+
for conn in connections_to_close:
|
|
958
|
+
try:
|
|
959
|
+
await conn.close()
|
|
960
|
+
except Exception as e:
|
|
961
|
+
self.logger.warning(f"Error closing connection during pool shutdown: {e}")
|
|
962
|
+
|
|
963
|
+
self.logger.info(f"Closed WebSocket connection pool for {self.uri}")
|
|
964
|
+
|
|
965
|
+
def __len__(self) -> int:
|
|
966
|
+
return self._total
|
|
967
|
+
|
|
968
|
+
def __repr__(self) -> str:
|
|
969
|
+
return (
|
|
970
|
+
f"{self.__class__.__name__}("
|
|
971
|
+
f"uri={self.uri}, "
|
|
972
|
+
f"total={self._total}, "
|
|
973
|
+
f"available={len(self._available)}, "
|
|
974
|
+
f"busy={len(self._busy)}, "
|
|
975
|
+
f"pending={len(self._pending)}, "
|
|
976
|
+
f"closed={self._closed})"
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
__str__ = __repr__
|
|
980
|
+
|
|
981
|
+
async def __aenter__(self) -> t.Self:
|
|
982
|
+
await self.start()
|
|
983
|
+
return self
|
|
984
|
+
|
|
985
|
+
async def __aexit__(
|
|
986
|
+
self,
|
|
987
|
+
exc_type: type[BaseException] | None,
|
|
988
|
+
exc_value: BaseException | None,
|
|
989
|
+
traceback: types.TracebackType | None,
|
|
990
|
+
) -> None:
|
|
991
|
+
await self.close_all()
|