bulklink 0.2.0__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.
- bulklink/__init__.py +43 -0
- bulklink/_internal/__init__.py +4 -0
- bulklink/_internal/cancellation.py +34 -0
- bulklink/_internal/coordinator.py +620 -0
- bulklink/_internal/diagnostics.py +254 -0
- bulklink/_internal/events.py +69 -0
- bulklink/_internal/models.py +61 -0
- bulklink/_internal/slot.py +63 -0
- bulklink/_internal/validation.py +51 -0
- bulklink/bulkhead.py +161 -0
- bulklink/capacity.py +154 -0
- bulklink/errors.py +53 -0
- bulklink/events.py +44 -0
- bulklink/py.typed +0 -0
- bulklink/registry.py +242 -0
- bulklink/status.py +100 -0
- bulklink/typing.py +8 -0
- bulklink-0.2.0.dist-info/METADATA +246 -0
- bulklink-0.2.0.dist-info/RECORD +22 -0
- bulklink-0.2.0.dist-info/WHEEL +5 -0
- bulklink-0.2.0.dist-info/licenses/LICENSE +21 -0
- bulklink-0.2.0.dist-info/top_level.txt +1 -0
bulklink/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Stable Bulklink public API."""
|
|
2
|
+
|
|
3
|
+
from bulklink.bulkhead import AsyncBulkhead
|
|
4
|
+
from bulklink.capacity import (
|
|
5
|
+
CapacityFinding,
|
|
6
|
+
CapacityFindingCode,
|
|
7
|
+
CapacityReport,
|
|
8
|
+
CapacitySeverity,
|
|
9
|
+
)
|
|
10
|
+
from bulklink.errors import (
|
|
11
|
+
BulkheadClosedError,
|
|
12
|
+
BulkheadQueueTimeoutError,
|
|
13
|
+
BulkheadSaturatedError,
|
|
14
|
+
BulklinkError,
|
|
15
|
+
)
|
|
16
|
+
from bulklink.events import BulkheadEvent, BulkheadEventHandler, BulkheadEventKind
|
|
17
|
+
from bulklink.registry import (
|
|
18
|
+
BulkheadRegistry,
|
|
19
|
+
BulkheadRegistryFailure,
|
|
20
|
+
BulkheadRegistryOperationError,
|
|
21
|
+
)
|
|
22
|
+
from bulklink.status import BulkheadStatus
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AsyncBulkhead",
|
|
26
|
+
"CapacityFinding",
|
|
27
|
+
"CapacityFindingCode",
|
|
28
|
+
"CapacityReport",
|
|
29
|
+
"CapacitySeverity",
|
|
30
|
+
"BulkheadClosedError",
|
|
31
|
+
"BulkheadEvent",
|
|
32
|
+
"BulkheadEventHandler",
|
|
33
|
+
"BulkheadEventKind",
|
|
34
|
+
"BulkheadQueueTimeoutError",
|
|
35
|
+
"BulkheadRegistry",
|
|
36
|
+
"BulkheadRegistryFailure",
|
|
37
|
+
"BulkheadRegistryOperationError",
|
|
38
|
+
"BulkheadSaturatedError",
|
|
39
|
+
"BulkheadStatus",
|
|
40
|
+
"BulklinkError",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Helpers for completing critical cleanup during task cancellation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Coroutine
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def complete_cleanup(action: Coroutine[Any, Any, T]) -> T:
|
|
13
|
+
"""Finish one cleanup coroutine despite repeated cancellation requests.
|
|
14
|
+
|
|
15
|
+
Cancellation is remembered and propagated only after the cleanup task has
|
|
16
|
+
finished. An exception raised by the cleanup itself takes precedence because
|
|
17
|
+
it indicates that internal state may not have been restored.
|
|
18
|
+
"""
|
|
19
|
+
cleanup_task = asyncio.create_task(action)
|
|
20
|
+
cancellation: asyncio.CancelledError | None = None
|
|
21
|
+
|
|
22
|
+
while not cleanup_task.done():
|
|
23
|
+
try:
|
|
24
|
+
await asyncio.shield(cleanup_task)
|
|
25
|
+
except asyncio.CancelledError as error:
|
|
26
|
+
if cancellation is None:
|
|
27
|
+
cancellation = error
|
|
28
|
+
|
|
29
|
+
result = cleanup_task.result()
|
|
30
|
+
|
|
31
|
+
if cancellation is not None:
|
|
32
|
+
raise cancellation
|
|
33
|
+
|
|
34
|
+
return result
|
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""Cancellation-safe FIFO admission coordination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections import deque
|
|
7
|
+
from time import monotonic, time
|
|
8
|
+
|
|
9
|
+
from bulklink._internal.cancellation import complete_cleanup
|
|
10
|
+
from bulklink._internal.events import EventDispatcher
|
|
11
|
+
from bulklink._internal.models import RuntimeCounters, WaitEntry, WaitState
|
|
12
|
+
from bulklink._internal.validation import (
|
|
13
|
+
require_label,
|
|
14
|
+
require_non_negative_integer,
|
|
15
|
+
require_optional_positive_number,
|
|
16
|
+
require_positive_integer,
|
|
17
|
+
resolve_wait_limit,
|
|
18
|
+
)
|
|
19
|
+
from bulklink.errors import (
|
|
20
|
+
BulkheadClosedError,
|
|
21
|
+
BulkheadQueueTimeoutError,
|
|
22
|
+
BulkheadSaturatedError,
|
|
23
|
+
)
|
|
24
|
+
from bulklink.events import BulkheadEvent, BulkheadEventHandler, BulkheadEventKind
|
|
25
|
+
from bulklink.status import BulkheadStatus
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AdmissionCoordinator:
|
|
29
|
+
"""Own all mutable state and synchronization for one async bulkhead."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
label: str,
|
|
35
|
+
parallelism: int,
|
|
36
|
+
waiting_room: int,
|
|
37
|
+
wait_limit: float | None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._label = require_label(label)
|
|
40
|
+
self._parallelism = require_positive_integer("parallelism", parallelism)
|
|
41
|
+
self._waiting_room = require_non_negative_integer("waiting_room", waiting_room)
|
|
42
|
+
self._wait_limit = require_optional_positive_number("wait_limit", wait_limit)
|
|
43
|
+
|
|
44
|
+
self._mutex = asyncio.Lock()
|
|
45
|
+
self._waiters: deque[WaitEntry] = deque()
|
|
46
|
+
self._in_flight = 0
|
|
47
|
+
self._closed = False
|
|
48
|
+
self._counters = RuntimeCounters()
|
|
49
|
+
self._owner_loop: asyncio.AbstractEventLoop | None = None
|
|
50
|
+
self._drained_event: asyncio.Event | None = None
|
|
51
|
+
self._event_dispatcher = EventDispatcher()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def label(self) -> str:
|
|
55
|
+
return self._label
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def parallelism(self) -> int:
|
|
59
|
+
return self._parallelism
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def waiting_room(self) -> int:
|
|
63
|
+
return self._waiting_room
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def wait_limit(self) -> float | None:
|
|
67
|
+
return self._wait_limit
|
|
68
|
+
|
|
69
|
+
def add_event_handler(self, handler: BulkheadEventHandler) -> None:
|
|
70
|
+
"""Register one synchronous observability handler."""
|
|
71
|
+
self._event_dispatcher.add(handler)
|
|
72
|
+
|
|
73
|
+
def remove_event_handler(self, handler: BulkheadEventHandler) -> None:
|
|
74
|
+
"""Remove one previously registered observability handler."""
|
|
75
|
+
self._event_dispatcher.remove(handler)
|
|
76
|
+
|
|
77
|
+
def effective_wait_limit(self, requested: float) -> float:
|
|
78
|
+
"""Return the shortest limit allowed for one queued admission."""
|
|
79
|
+
return resolve_wait_limit(self._wait_limit, requested)
|
|
80
|
+
|
|
81
|
+
async def resize(self, parallelism: int) -> None:
|
|
82
|
+
"""Change execution capacity without cancelling admitted operations."""
|
|
83
|
+
requested = require_positive_integer("parallelism", parallelism)
|
|
84
|
+
self._bind_to_running_loop()
|
|
85
|
+
error: Exception | None = None
|
|
86
|
+
|
|
87
|
+
async with self._mutex:
|
|
88
|
+
if self._closed:
|
|
89
|
+
error = BulkheadClosedError(label=self._label)
|
|
90
|
+
events: tuple[BulkheadEvent, ...] = ()
|
|
91
|
+
elif requested == self._parallelism:
|
|
92
|
+
events = ()
|
|
93
|
+
else:
|
|
94
|
+
previous = self._parallelism
|
|
95
|
+
occurred_at = time()
|
|
96
|
+
affected_waiters = min(
|
|
97
|
+
len(self._waiters),
|
|
98
|
+
max(0, requested - self._in_flight),
|
|
99
|
+
)
|
|
100
|
+
self._parallelism = requested
|
|
101
|
+
resized = self._event_locked(
|
|
102
|
+
BulkheadEventKind.RESIZED,
|
|
103
|
+
occurred_at=occurred_at,
|
|
104
|
+
previous_parallelism=previous,
|
|
105
|
+
affected_waiters=affected_waiters,
|
|
106
|
+
)
|
|
107
|
+
admitted = self._admit_available_waiters_locked(
|
|
108
|
+
occurred_at=occurred_at,
|
|
109
|
+
)
|
|
110
|
+
events = (resized, *admitted)
|
|
111
|
+
|
|
112
|
+
self._event_dispatcher.dispatch(events)
|
|
113
|
+
if error is not None:
|
|
114
|
+
raise error
|
|
115
|
+
|
|
116
|
+
def _bind_to_running_loop(self) -> asyncio.AbstractEventLoop:
|
|
117
|
+
loop = asyncio.get_running_loop()
|
|
118
|
+
if self._owner_loop is None:
|
|
119
|
+
self._owner_loop = loop
|
|
120
|
+
self._drained_event = asyncio.Event()
|
|
121
|
+
elif self._owner_loop is not loop:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"bulkhead {self._label!r} cannot be shared across different event loops"
|
|
124
|
+
)
|
|
125
|
+
return loop
|
|
126
|
+
|
|
127
|
+
async def enter(self) -> None:
|
|
128
|
+
"""Admit immediately, queue, or reject using the configured wait limit."""
|
|
129
|
+
await self._enter(self._wait_limit)
|
|
130
|
+
|
|
131
|
+
async def enter_within(self, wait_limit: float) -> None:
|
|
132
|
+
"""Admit using a per-call wait limit no longer than the configured limit."""
|
|
133
|
+
await self._enter(self.effective_wait_limit(wait_limit))
|
|
134
|
+
|
|
135
|
+
async def _enter(self, wait_limit: float | None) -> None:
|
|
136
|
+
loop = self._bind_to_running_loop()
|
|
137
|
+
entry: WaitEntry | None = None
|
|
138
|
+
error: Exception | None = None
|
|
139
|
+
events: tuple[BulkheadEvent, ...]
|
|
140
|
+
|
|
141
|
+
async with self._mutex:
|
|
142
|
+
if self._closed:
|
|
143
|
+
self._counters.closed_before_queue_total += 1
|
|
144
|
+
error = BulkheadClosedError(label=self._label)
|
|
145
|
+
events = (self._event_locked(BulkheadEventKind.CLOSED_REJECTION),)
|
|
146
|
+
elif self._can_admit_directly_locked():
|
|
147
|
+
events = (self._grant_directly_locked(),)
|
|
148
|
+
elif len(self._waiters) >= self._waiting_room:
|
|
149
|
+
self._counters.saturated_total += 1
|
|
150
|
+
error = BulkheadSaturatedError(
|
|
151
|
+
label=self._label,
|
|
152
|
+
in_flight=self._in_flight,
|
|
153
|
+
waiting=len(self._waiters),
|
|
154
|
+
parallelism=self._parallelism,
|
|
155
|
+
waiting_room=self._waiting_room,
|
|
156
|
+
)
|
|
157
|
+
events = (self._event_locked(BulkheadEventKind.SATURATED),)
|
|
158
|
+
else:
|
|
159
|
+
entry = WaitEntry(
|
|
160
|
+
future=loop.create_future(),
|
|
161
|
+
enqueued_at=monotonic(),
|
|
162
|
+
)
|
|
163
|
+
self._waiters.append(entry)
|
|
164
|
+
self._counters.queued_total += 1
|
|
165
|
+
self._counters.peak_waiting = max(
|
|
166
|
+
self._counters.peak_waiting,
|
|
167
|
+
len(self._waiters),
|
|
168
|
+
)
|
|
169
|
+
events = (
|
|
170
|
+
self._event_locked(
|
|
171
|
+
BulkheadEventKind.QUEUED,
|
|
172
|
+
from_queue=True,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self._event_dispatcher.dispatch(events)
|
|
177
|
+
if error is not None:
|
|
178
|
+
raise error
|
|
179
|
+
if entry is None:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
state = await self._await_terminal_state(entry, wait_limit)
|
|
184
|
+
except asyncio.TimeoutError as timeout_error:
|
|
185
|
+
try:
|
|
186
|
+
state = await complete_cleanup(self._expire_waiter(entry))
|
|
187
|
+
except asyncio.CancelledError:
|
|
188
|
+
await complete_cleanup(self._cancel_waiter(entry))
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
if state is WaitState.ADMITTED:
|
|
192
|
+
return
|
|
193
|
+
if state is WaitState.CLOSED:
|
|
194
|
+
raise BulkheadClosedError(label=self._label) from timeout_error
|
|
195
|
+
if state is not WaitState.EXPIRED:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"unexpected wait state after timeout: {state.name}"
|
|
198
|
+
) from timeout_error
|
|
199
|
+
|
|
200
|
+
if wait_limit is None:
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
"a queued admission expired without a wait limit"
|
|
203
|
+
) from timeout_error
|
|
204
|
+
raise BulkheadQueueTimeoutError(
|
|
205
|
+
label=self._label,
|
|
206
|
+
wait_limit=wait_limit,
|
|
207
|
+
) from timeout_error
|
|
208
|
+
except asyncio.CancelledError:
|
|
209
|
+
await complete_cleanup(self._cancel_waiter(entry))
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
if state is WaitState.ADMITTED:
|
|
213
|
+
return
|
|
214
|
+
if state is WaitState.CLOSED:
|
|
215
|
+
raise BulkheadClosedError(label=self._label)
|
|
216
|
+
if state is WaitState.EXPIRED:
|
|
217
|
+
if wait_limit is None:
|
|
218
|
+
raise RuntimeError("a queued admission expired without a wait limit")
|
|
219
|
+
raise BulkheadQueueTimeoutError(
|
|
220
|
+
label=self._label,
|
|
221
|
+
wait_limit=wait_limit,
|
|
222
|
+
)
|
|
223
|
+
if state is WaitState.CANCELLED:
|
|
224
|
+
raise asyncio.CancelledError
|
|
225
|
+
|
|
226
|
+
raise RuntimeError(f"unexpected terminal wait state: {state.name}")
|
|
227
|
+
|
|
228
|
+
async def enter_now(self) -> None:
|
|
229
|
+
"""Admit only when capacity is available without joining the waiting room."""
|
|
230
|
+
self._bind_to_running_loop()
|
|
231
|
+
error: Exception | None = None
|
|
232
|
+
|
|
233
|
+
async with self._mutex:
|
|
234
|
+
if self._closed:
|
|
235
|
+
self._counters.closed_before_queue_total += 1
|
|
236
|
+
error = BulkheadClosedError(label=self._label)
|
|
237
|
+
event = self._event_locked(BulkheadEventKind.CLOSED_REJECTION)
|
|
238
|
+
elif self._can_admit_directly_locked():
|
|
239
|
+
event = self._grant_directly_locked()
|
|
240
|
+
else:
|
|
241
|
+
self._counters.saturated_total += 1
|
|
242
|
+
error = BulkheadSaturatedError(
|
|
243
|
+
label=self._label,
|
|
244
|
+
in_flight=self._in_flight,
|
|
245
|
+
waiting=len(self._waiters),
|
|
246
|
+
parallelism=self._parallelism,
|
|
247
|
+
waiting_room=self._waiting_room,
|
|
248
|
+
)
|
|
249
|
+
event = self._event_locked(BulkheadEventKind.SATURATED)
|
|
250
|
+
|
|
251
|
+
self._event_dispatcher.dispatch((event,))
|
|
252
|
+
if error is not None:
|
|
253
|
+
raise error
|
|
254
|
+
|
|
255
|
+
def _can_admit_directly_locked(self) -> bool:
|
|
256
|
+
return self._in_flight < self._parallelism and not self._waiters
|
|
257
|
+
|
|
258
|
+
async def _await_terminal_state(
|
|
259
|
+
self,
|
|
260
|
+
entry: WaitEntry,
|
|
261
|
+
wait_limit: float | None,
|
|
262
|
+
) -> WaitState:
|
|
263
|
+
if wait_limit is None:
|
|
264
|
+
return await asyncio.shield(entry.future)
|
|
265
|
+
return await asyncio.wait_for(
|
|
266
|
+
asyncio.shield(entry.future),
|
|
267
|
+
timeout=wait_limit,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _grant_directly_locked(self) -> BulkheadEvent:
|
|
271
|
+
self._allocate_capacity_locked()
|
|
272
|
+
self._counters.admitted_total += 1
|
|
273
|
+
return self._event_locked(BulkheadEventKind.ADMITTED)
|
|
274
|
+
|
|
275
|
+
def _allocate_capacity_locked(self) -> None:
|
|
276
|
+
self._in_flight += 1
|
|
277
|
+
self._counters.peak_in_flight = max(
|
|
278
|
+
self._counters.peak_in_flight,
|
|
279
|
+
self._in_flight,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _admit_available_waiters_locked(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
occurred_at: float,
|
|
286
|
+
) -> tuple[BulkheadEvent, ...]:
|
|
287
|
+
events: list[BulkheadEvent] = []
|
|
288
|
+
while self._waiters and self._in_flight < self._parallelism:
|
|
289
|
+
entry = self._waiters.popleft()
|
|
290
|
+
if entry.future.done():
|
|
291
|
+
raise RuntimeError("waiting entry future completed before admission")
|
|
292
|
+
|
|
293
|
+
self._allocate_capacity_locked()
|
|
294
|
+
_, event = self._finish_waiter_locked(
|
|
295
|
+
entry,
|
|
296
|
+
WaitState.ADMITTED,
|
|
297
|
+
remove_from_queue=False,
|
|
298
|
+
occurred_at=occurred_at,
|
|
299
|
+
)
|
|
300
|
+
events.append(event)
|
|
301
|
+
|
|
302
|
+
return tuple(events)
|
|
303
|
+
|
|
304
|
+
async def _expire_waiter(self, entry: WaitEntry) -> WaitState:
|
|
305
|
+
"""Expire one entry if it is still waiting."""
|
|
306
|
+
events: tuple[BulkheadEvent, ...] = ()
|
|
307
|
+
async with self._mutex:
|
|
308
|
+
if entry.state is WaitState.WAITING:
|
|
309
|
+
state, event = self._finish_waiter_locked(
|
|
310
|
+
entry,
|
|
311
|
+
WaitState.EXPIRED,
|
|
312
|
+
remove_from_queue=True,
|
|
313
|
+
)
|
|
314
|
+
events = (event,)
|
|
315
|
+
else:
|
|
316
|
+
state = entry.state
|
|
317
|
+
|
|
318
|
+
self._event_dispatcher.dispatch(events)
|
|
319
|
+
return state
|
|
320
|
+
|
|
321
|
+
async def _cancel_waiter(self, entry: WaitEntry) -> WaitState:
|
|
322
|
+
"""Cancel waiting or return a slot already transferred to this entry."""
|
|
323
|
+
events: tuple[BulkheadEvent, ...] = ()
|
|
324
|
+
async with self._mutex:
|
|
325
|
+
if entry.state is WaitState.WAITING:
|
|
326
|
+
state, event = self._finish_waiter_locked(
|
|
327
|
+
entry,
|
|
328
|
+
WaitState.CANCELLED,
|
|
329
|
+
remove_from_queue=True,
|
|
330
|
+
)
|
|
331
|
+
events = (event,)
|
|
332
|
+
elif entry.state is WaitState.ADMITTED:
|
|
333
|
+
state = entry.state
|
|
334
|
+
events = self._abandon_admitted_slot_locked(entry)
|
|
335
|
+
else:
|
|
336
|
+
state = entry.state
|
|
337
|
+
|
|
338
|
+
self._event_dispatcher.dispatch(events)
|
|
339
|
+
return state
|
|
340
|
+
|
|
341
|
+
def _finish_waiter_locked(
|
|
342
|
+
self,
|
|
343
|
+
entry: WaitEntry,
|
|
344
|
+
state: WaitState,
|
|
345
|
+
*,
|
|
346
|
+
remove_from_queue: bool,
|
|
347
|
+
occurred_at: float | None = None,
|
|
348
|
+
) -> tuple[WaitState, BulkheadEvent]:
|
|
349
|
+
"""Move one waiting entry to a terminal state under the coordinator lock."""
|
|
350
|
+
if state is WaitState.WAITING:
|
|
351
|
+
raise ValueError("a waiting entry requires a terminal state")
|
|
352
|
+
if entry.state is not WaitState.WAITING:
|
|
353
|
+
raise RuntimeError("only waiting entries can be completed")
|
|
354
|
+
|
|
355
|
+
if remove_from_queue:
|
|
356
|
+
self._remove_waiter_locked(entry)
|
|
357
|
+
|
|
358
|
+
if not entry.transition_to(state):
|
|
359
|
+
raise RuntimeError("waiting entry could not transition to a terminal state")
|
|
360
|
+
|
|
361
|
+
waited = max(0.0, monotonic() - entry.enqueued_at)
|
|
362
|
+
if state is WaitState.ADMITTED:
|
|
363
|
+
entry.waited_seconds = waited
|
|
364
|
+
self._counters.admitted_total += 1
|
|
365
|
+
self._counters.admitted_from_queue_total += 1
|
|
366
|
+
self._counters.cumulative_wait_seconds += waited
|
|
367
|
+
self._counters.longest_wait_seconds = max(
|
|
368
|
+
self._counters.longest_wait_seconds,
|
|
369
|
+
waited,
|
|
370
|
+
)
|
|
371
|
+
entry.future.set_result(WaitState.ADMITTED)
|
|
372
|
+
event = self._event_locked(
|
|
373
|
+
BulkheadEventKind.ADMITTED,
|
|
374
|
+
occurred_at=occurred_at,
|
|
375
|
+
from_queue=True,
|
|
376
|
+
waited_seconds=waited,
|
|
377
|
+
)
|
|
378
|
+
elif state is WaitState.CANCELLED:
|
|
379
|
+
self._counters.cancelled_while_waiting_total += 1
|
|
380
|
+
entry.future.cancel()
|
|
381
|
+
event = self._event_locked(
|
|
382
|
+
BulkheadEventKind.CANCELLED,
|
|
383
|
+
occurred_at=occurred_at,
|
|
384
|
+
from_queue=True,
|
|
385
|
+
waited_seconds=waited,
|
|
386
|
+
)
|
|
387
|
+
elif state is WaitState.EXPIRED:
|
|
388
|
+
self._counters.expired_total += 1
|
|
389
|
+
entry.future.set_result(WaitState.EXPIRED)
|
|
390
|
+
event = self._event_locked(
|
|
391
|
+
BulkheadEventKind.EXPIRED,
|
|
392
|
+
occurred_at=occurred_at,
|
|
393
|
+
from_queue=True,
|
|
394
|
+
waited_seconds=waited,
|
|
395
|
+
)
|
|
396
|
+
elif state is WaitState.CLOSED:
|
|
397
|
+
self._counters.closed_while_waiting_total += 1
|
|
398
|
+
entry.future.set_result(WaitState.CLOSED)
|
|
399
|
+
event = self._event_locked(
|
|
400
|
+
BulkheadEventKind.CLOSED_REJECTION,
|
|
401
|
+
occurred_at=occurred_at,
|
|
402
|
+
from_queue=True,
|
|
403
|
+
waited_seconds=waited,
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
raise RuntimeError(f"unsupported terminal wait state: {state.name}")
|
|
407
|
+
|
|
408
|
+
return state, event
|
|
409
|
+
|
|
410
|
+
def _remove_waiter_locked(self, entry: WaitEntry) -> None:
|
|
411
|
+
try:
|
|
412
|
+
self._waiters.remove(entry)
|
|
413
|
+
except ValueError as error:
|
|
414
|
+
raise RuntimeError("waiting entry is missing from the FIFO queue") from error
|
|
415
|
+
|
|
416
|
+
async def release(self) -> None:
|
|
417
|
+
"""Finish one protected operation and release or transfer its slot."""
|
|
418
|
+
self._bind_to_running_loop()
|
|
419
|
+
|
|
420
|
+
async with self._mutex:
|
|
421
|
+
events = self._finish_admitted_slot_locked()
|
|
422
|
+
|
|
423
|
+
self._event_dispatcher.dispatch(events)
|
|
424
|
+
|
|
425
|
+
def _finish_admitted_slot_locked(self) -> tuple[BulkheadEvent, ...]:
|
|
426
|
+
if self._in_flight <= 0:
|
|
427
|
+
raise RuntimeError("execution slot released without a matching admission")
|
|
428
|
+
|
|
429
|
+
occurred_at = time()
|
|
430
|
+
self._counters.finished_total += 1
|
|
431
|
+
|
|
432
|
+
capacity_events = self._release_capacity_locked(
|
|
433
|
+
occurred_at=occurred_at,
|
|
434
|
+
)
|
|
435
|
+
released = self._event_locked(
|
|
436
|
+
BulkheadEventKind.RELEASED,
|
|
437
|
+
occurred_at=occurred_at,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return (released, *capacity_events)
|
|
441
|
+
|
|
442
|
+
def _abandon_admitted_slot_locked(
|
|
443
|
+
self,
|
|
444
|
+
entry: WaitEntry,
|
|
445
|
+
) -> tuple[BulkheadEvent, ...]:
|
|
446
|
+
if self._in_flight <= 0:
|
|
447
|
+
raise RuntimeError("admitted slot abandoned without allocated capacity")
|
|
448
|
+
if entry.waited_seconds is None:
|
|
449
|
+
raise RuntimeError("admitted queue entry is missing its wait duration")
|
|
450
|
+
|
|
451
|
+
occurred_at = time()
|
|
452
|
+
self._counters.abandoned_after_admission_total += 1
|
|
453
|
+
|
|
454
|
+
capacity_events = self._release_capacity_locked(
|
|
455
|
+
occurred_at=occurred_at,
|
|
456
|
+
)
|
|
457
|
+
abandoned = self._event_locked(
|
|
458
|
+
BulkheadEventKind.ABANDONED,
|
|
459
|
+
occurred_at=occurred_at,
|
|
460
|
+
from_queue=True,
|
|
461
|
+
waited_seconds=entry.waited_seconds,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return (abandoned, *capacity_events)
|
|
465
|
+
|
|
466
|
+
def _release_capacity_locked(
|
|
467
|
+
self,
|
|
468
|
+
*,
|
|
469
|
+
occurred_at: float | None = None,
|
|
470
|
+
) -> tuple[BulkheadEvent, ...]:
|
|
471
|
+
if self._waiters and self._in_flight <= self._parallelism:
|
|
472
|
+
entry = self._waiters.popleft()
|
|
473
|
+
|
|
474
|
+
if entry.future.done():
|
|
475
|
+
raise RuntimeError("waiting entry future completed before admission")
|
|
476
|
+
|
|
477
|
+
_, event = self._finish_waiter_locked(
|
|
478
|
+
entry,
|
|
479
|
+
WaitState.ADMITTED,
|
|
480
|
+
remove_from_queue=False,
|
|
481
|
+
occurred_at=occurred_at,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Direct transfer keeps _in_flight unchanged.
|
|
485
|
+
return (event,)
|
|
486
|
+
|
|
487
|
+
self._in_flight -= 1
|
|
488
|
+
|
|
489
|
+
drained = self._signal_drained_if_ready_locked(
|
|
490
|
+
occurred_at=occurred_at,
|
|
491
|
+
)
|
|
492
|
+
if drained is None:
|
|
493
|
+
return ()
|
|
494
|
+
|
|
495
|
+
return (drained,)
|
|
496
|
+
|
|
497
|
+
def _drain_signal(self) -> asyncio.Event:
|
|
498
|
+
event = self._drained_event
|
|
499
|
+
if event is None:
|
|
500
|
+
raise RuntimeError("bulkhead drain signal is unavailable before event-loop binding")
|
|
501
|
+
return event
|
|
502
|
+
|
|
503
|
+
def _signal_drained_if_ready_locked(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
occurred_at: float | None = None,
|
|
507
|
+
) -> BulkheadEvent | None:
|
|
508
|
+
if not self._closed or self._in_flight != 0:
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
if self._waiters:
|
|
512
|
+
raise RuntimeError("a closed and drained bulkhead cannot retain queued entries")
|
|
513
|
+
|
|
514
|
+
signal = self._drain_signal()
|
|
515
|
+
if signal.is_set():
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
signal.set()
|
|
519
|
+
|
|
520
|
+
return self._event_locked(
|
|
521
|
+
BulkheadEventKind.DRAINED,
|
|
522
|
+
occurred_at=occurred_at,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def close(self) -> None:
|
|
526
|
+
"""Close admission and wake queued operations with a closed state."""
|
|
527
|
+
self._bind_to_running_loop()
|
|
528
|
+
|
|
529
|
+
async with self._mutex:
|
|
530
|
+
if self._closed:
|
|
531
|
+
events: tuple[BulkheadEvent, ...] = ()
|
|
532
|
+
else:
|
|
533
|
+
affected_waiters = len(self._waiters)
|
|
534
|
+
self._closed = True
|
|
535
|
+
pending_events: list[BulkheadEvent] = [
|
|
536
|
+
self._event_locked(
|
|
537
|
+
BulkheadEventKind.CLOSED,
|
|
538
|
+
affected_waiters=affected_waiters,
|
|
539
|
+
)
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
while self._waiters:
|
|
543
|
+
entry = self._waiters.popleft()
|
|
544
|
+
_, event = self._finish_waiter_locked(
|
|
545
|
+
entry,
|
|
546
|
+
WaitState.CLOSED,
|
|
547
|
+
remove_from_queue=False,
|
|
548
|
+
)
|
|
549
|
+
pending_events.append(event)
|
|
550
|
+
|
|
551
|
+
drained = self._signal_drained_if_ready_locked()
|
|
552
|
+
if drained is not None:
|
|
553
|
+
pending_events.append(drained)
|
|
554
|
+
events = tuple(pending_events)
|
|
555
|
+
|
|
556
|
+
self._event_dispatcher.dispatch(events)
|
|
557
|
+
|
|
558
|
+
async def wait_closed(self) -> None:
|
|
559
|
+
"""Wait until admission is closed and every active operation has left."""
|
|
560
|
+
self._bind_to_running_loop()
|
|
561
|
+
await self._drain_signal().wait()
|
|
562
|
+
|
|
563
|
+
async def close_and_wait(self) -> None:
|
|
564
|
+
"""Close admission safely, then wait until active work has drained."""
|
|
565
|
+
await complete_cleanup(self.close())
|
|
566
|
+
await self.wait_closed()
|
|
567
|
+
|
|
568
|
+
async def status(self) -> BulkheadStatus:
|
|
569
|
+
"""Build an immutable status report under the coordinator lock."""
|
|
570
|
+
self._bind_to_running_loop()
|
|
571
|
+
|
|
572
|
+
async with self._mutex:
|
|
573
|
+
counters = self._counters
|
|
574
|
+
return BulkheadStatus(
|
|
575
|
+
label=self._label,
|
|
576
|
+
parallelism=self._parallelism,
|
|
577
|
+
waiting_room=self._waiting_room,
|
|
578
|
+
in_flight=self._in_flight,
|
|
579
|
+
waiting=len(self._waiters),
|
|
580
|
+
admitted_total=counters.admitted_total,
|
|
581
|
+
admitted_from_queue_total=counters.admitted_from_queue_total,
|
|
582
|
+
abandoned_after_admission_total=counters.abandoned_after_admission_total,
|
|
583
|
+
queued_total=counters.queued_total,
|
|
584
|
+
saturated_total=counters.saturated_total,
|
|
585
|
+
expired_total=counters.expired_total,
|
|
586
|
+
cancelled_while_waiting_total=counters.cancelled_while_waiting_total,
|
|
587
|
+
closed_before_queue_total=counters.closed_before_queue_total,
|
|
588
|
+
closed_while_waiting_total=counters.closed_while_waiting_total,
|
|
589
|
+
finished_total=counters.finished_total,
|
|
590
|
+
peak_in_flight=counters.peak_in_flight,
|
|
591
|
+
peak_waiting=counters.peak_waiting,
|
|
592
|
+
cumulative_wait_seconds=counters.cumulative_wait_seconds,
|
|
593
|
+
longest_wait_seconds=counters.longest_wait_seconds,
|
|
594
|
+
is_closed=self._closed,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def _event_locked(
|
|
598
|
+
self,
|
|
599
|
+
kind: BulkheadEventKind,
|
|
600
|
+
*,
|
|
601
|
+
occurred_at: float | None = None,
|
|
602
|
+
from_queue: bool = False,
|
|
603
|
+
waited_seconds: float | None = None,
|
|
604
|
+
affected_waiters: int = 0,
|
|
605
|
+
previous_parallelism: int | None = None,
|
|
606
|
+
) -> BulkheadEvent:
|
|
607
|
+
return BulkheadEvent(
|
|
608
|
+
kind=kind,
|
|
609
|
+
label=self._label,
|
|
610
|
+
occurred_at=time() if occurred_at is None else occurred_at,
|
|
611
|
+
parallelism=self._parallelism,
|
|
612
|
+
waiting_room=self._waiting_room,
|
|
613
|
+
in_flight=self._in_flight,
|
|
614
|
+
waiting=len(self._waiters),
|
|
615
|
+
is_closed=self._closed,
|
|
616
|
+
from_queue=from_queue,
|
|
617
|
+
waited_seconds=waited_seconds,
|
|
618
|
+
affected_waiters=affected_waiters,
|
|
619
|
+
previous_parallelism=previous_parallelism,
|
|
620
|
+
)
|