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 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,4 @@
1
+ """Private Bulklink implementation details.
2
+
3
+ Nothing in this package is part of the stable public API.
4
+ """
@@ -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
+ )