Skip to content

Commit 2230024

Browse files
committed
refactor: Replace AsyncWorkerPool with BoundedTaskSet
The async event delivery concurrency limiter no longer mirrors the sync FixedThreadPool's shape. BoundedTaskSet drops the unused name parameter and the thread vocabulary, reserves a slot synchronously at spawn (so a full set rejects rather than queues), and uses a done-callback plus asyncio.gather for cleanup and draining.
1 parent ccb9c58 commit 2230024

3 files changed

Lines changed: 63 additions & 71 deletions

File tree

ldclient/impl/aio/concurrency.py

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Async concurrency primitives used by the async data source, event processor, and
33
data system. Each wraps a piece of fiddly asyncio plumbing (timeout-aware waits,
44
queue exception normalization, an interval-from-start repeating task, a bounded
5-
task pool) that callers would otherwise inline repeatedly. The sync code uses the
5+
task set) that callers would otherwise inline repeatedly. The sync code uses the
66
equivalent stdlib/SDK primitives (``threading.Event``/``Lock``, ``queue.Queue``,
77
``RepeatingTask``, ``FixedThreadPool``) directly, so these have no sync twin.
88
"""
@@ -12,7 +12,7 @@
1212
import time
1313
from queue import Empty as QueueEmpty # noqa: F401 (shared timeout exception)
1414
from queue import Full as QueueFull # noqa: F401 (shared capacity exception)
15-
from typing import Any, Callable, Optional, Set
15+
from typing import Any, Awaitable, Callable, Optional, Set
1616

1717
from ldclient.impl.util import log
1818

@@ -250,50 +250,37 @@ async def _run(self):
250250
pass
251251

252252

253-
class AsyncWorkerPool:
254-
"""A fixed-size pool of concurrent tasks that rejects jobs when its limit
255-
is reached. Matches the contract of
256-
``ldclient.impl.fixed_thread_pool.FixedThreadPool``."""
253+
class BoundedTaskSet:
254+
"""Runs up to ``limit`` coroutines concurrently as background tasks. When the
255+
limit is reached, ``try_spawn`` rejects new work (returning False) rather than
256+
queuing it, so callers can apply their own backpressure. ``drain`` awaits all
257+
in-flight tasks; ``stop`` prevents any further work from being accepted."""
257258

258-
def __init__(self, size: int, name: str):
259-
self._size = size
260-
self._name = name
261-
self._busy: Set[asyncio.Task] = set()
262-
self._event = AsyncEvent()
263-
self._stopped = False
259+
def __init__(self, limit: int):
260+
self._limit = limit
261+
self._tasks: Set[asyncio.Task] = set()
262+
self._accepting = True
264263

265-
def execute(self, jobFn: Callable) -> bool:
266-
"""Schedules a job for execution if the pool is not already at its
267-
limit, and returns True if successful; returns False if all workers
268-
are busy."""
269-
if self._stopped or len(self._busy) >= self._size:
264+
def try_spawn(self, job: Callable[[], Awaitable]) -> bool:
265+
"""Starts ``job()`` as a background task if fewer than ``limit`` tasks are
266+
already running and the set is still accepting work. Returns True if the
267+
task was started, or False if the set is full or has been stopped."""
268+
if not self._accepting or len(self._tasks) >= self._limit:
270269
return False
271-
task = asyncio.ensure_future(self._run_job(jobFn))
272-
self._busy.add(task)
270+
task = asyncio.ensure_future(job())
271+
self._tasks.add(task)
272+
task.add_done_callback(self._on_done)
273273
return True
274274

275-
async def _run_job(self, jobFn: Callable) -> None:
276-
try:
277-
result = jobFn()
278-
if inspect.isawaitable(result):
279-
await result
280-
except Exception:
281-
log.warning('Unhandled exception in worker thread', exc_info=True)
282-
finally:
283-
task = asyncio.current_task()
284-
if task is not None:
285-
self._busy.discard(task)
286-
self._event.set()
287-
288-
async def wait(self) -> None:
289-
"""Waits until all currently busy workers have completed their jobs."""
290-
while len(self._busy) > 0:
291-
self._event.clear()
292-
if len(self._busy) == 0:
293-
return
294-
await self._event.wait()
275+
def _on_done(self, task: asyncio.Task) -> None:
276+
self._tasks.discard(task)
277+
if not task.cancelled() and task.exception() is not None:
278+
log.warning('Unhandled exception in background task', exc_info=task.exception())
279+
280+
async def drain(self) -> None:
281+
"""Waits for all currently running tasks to complete."""
282+
await asyncio.gather(*self._tasks, return_exceptions=True)
295283

296284
def stop(self) -> None:
297-
"""Tells the pool to reject any further jobs; active jobs run to
298-
completion."""
299-
self._stopped = True
285+
"""Rejects any further work; tasks already running finish normally."""
286+
self._accepting = False

ldclient/impl/events/async_event_processor.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
AsyncQueue,
2020
AsyncRepeatingTask,
2121
AsyncTaskRunner,
22-
AsyncWorkerPool
22+
BoundedTaskSet
2323
)
2424
from ldclient.impl.aio.transport import AsyncHTTPTransport
2525
from ldclient.impl.events.diagnostics import create_diagnostic_init
@@ -38,7 +38,7 @@
3838
)
3939
from ldclient.interfaces import AsyncEventProcessor
4040

41-
__MAX_FLUSH_THREADS__ = 5
41+
__MAX_FLUSH_CONCURRENCY__ = 5
4242
__CURRENT_EVENT_SCHEMA__ = 4
4343

4444

@@ -108,13 +108,13 @@ def __init__(self, inbox: AsyncQueue, config: AsyncConfig, http_client, diagnost
108108
self._sampler = Sampler(Random())
109109
self._omit_anonymous_contexts = config.omit_anonymous_contexts
110110

111-
self._flush_workers = AsyncWorkerPool(__MAX_FLUSH_THREADS__, "ldclient.flush")
112-
self._diagnostic_flush_workers: Optional[AsyncWorkerPool] = None
111+
self._flush_workers = BoundedTaskSet(__MAX_FLUSH_CONCURRENCY__)
112+
self._diagnostic_flush_workers: Optional[BoundedTaskSet] = None
113113
if self._diagnostic_accumulator is not None:
114-
self._diagnostic_flush_workers = AsyncWorkerPool(1, "ldclient.events.diag_flush")
114+
self._diagnostic_flush_workers = BoundedTaskSet(1)
115115
init_event = create_diagnostic_init(self._diagnostic_accumulator.data_since_date, self._diagnostic_accumulator.diagnostic_id, config)
116116
task = DiagnosticEventSendTask(self._http, self._config, init_event)
117-
self._diagnostic_flush_workers.execute(task.run)
117+
self._diagnostic_flush_workers.try_spawn(task.run)
118118

119119
self._runner = AsyncTaskRunner()
120120
self._runner.spawn("ldclient.events.processor", self._run_main_loop)
@@ -134,12 +134,12 @@ async def _run_main_loop(self):
134134
self._send_and_reset_diagnostics()
135135
elif message.type == 'flush_and_wait':
136136
self._trigger_flush()
137-
await self._flush_workers.wait()
137+
await self._flush_workers.drain()
138138
message.param.set()
139139
elif message.type == 'test_sync':
140-
await self._flush_workers.wait()
140+
await self._flush_workers.drain()
141141
if self._diagnostic_flush_workers is not None:
142-
await self._diagnostic_flush_workers.wait()
142+
await self._diagnostic_flush_workers.drain()
143143
message.param.set()
144144
elif message.type == 'stop':
145145
await self._do_shutdown()
@@ -156,7 +156,7 @@ def _trigger_flush(self):
156156
self._diagnostic_accumulator.record_events_in_batch(len(payload.events))
157157
if len(payload.events) > 0 or not payload.summary.is_empty():
158158
task = EventPayloadSendTask(self._http, self._config, self._formatter, payload, self._handle_response)
159-
if self._flush_workers.execute(task.run):
159+
if self._flush_workers.try_spawn(task.run):
160160
# The events have been handed off to a flush worker; clear them from our buffer.
161161
self._outbox.clear()
162162
else:
@@ -169,15 +169,15 @@ def _send_and_reset_diagnostics(self):
169169
stats_event = self._diagnostic_accumulator.create_event_and_reset(dropped_event_count, self._deduplicated_contexts)
170170
self._deduplicated_contexts = 0
171171
task = DiagnosticEventSendTask(self._http, self._config, stats_event)
172-
self._diagnostic_flush_workers.execute(task.run)
172+
self._diagnostic_flush_workers.try_spawn(task.run)
173173

174174
async def _do_shutdown(self):
175175
self._flush_workers.stop()
176-
await self._flush_workers.wait()
176+
await self._flush_workers.drain()
177177

178178
if self._diagnostic_flush_workers is not None:
179179
self._diagnostic_flush_workers.stop()
180-
await self._diagnostic_flush_workers.wait()
180+
await self._diagnostic_flush_workers.drain()
181181

182182
await self._http.close()
183183

ldclient/testing/test_aio.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -203,38 +203,43 @@ async def action():
203203

204204

205205
# ---------------------------------------------------------------------------
206-
# WorkerPool
206+
# BoundedTaskSet
207207
# ---------------------------------------------------------------------------
208208

209-
class TestWorkerPoolParity:
209+
class TestBoundedTaskSet:
210210
@pytest.mark.asyncio
211-
async def test_async_saturation_returns_false(self):
212-
pool = aio.AsyncWorkerPool(1, "test.pool")
211+
async def test_saturation_returns_false(self):
212+
tasks = aio.BoundedTaskSet(1)
213213
release = aio.AsyncEvent()
214214
started = aio.AsyncEvent()
215215

216216
async def job():
217217
started.set()
218218
await release.wait(2)
219219

220-
assert pool.execute(job) is True
220+
async def noop():
221+
pass
222+
223+
assert tasks.try_spawn(job) is True
221224
await started.wait(2)
222-
assert pool.execute(lambda: None) is False
225+
# Set is full (limit 1), so the next spawn is rejected rather than queued.
226+
assert tasks.try_spawn(noop) is False
223227
release.set()
224-
await pool.wait()
228+
await tasks.drain()
225229

226-
async def noop():
227-
pass
230+
# A slot is free again once the first task drained.
231+
assert tasks.try_spawn(noop) is True
232+
await tasks.drain()
228233

229-
assert pool.execute(noop) is True
230-
await pool.wait()
231-
pool.stop()
234+
# After stop(), further work is rejected.
235+
tasks.stop()
236+
assert tasks.try_spawn(noop) is False
232237

233238
@pytest.mark.asyncio
234-
async def test_async_wait_returns_when_idle(self):
235-
pool = aio.AsyncWorkerPool(2, "test.pool")
236-
await pool.wait()
237-
pool.stop()
239+
async def test_drain_returns_when_idle(self):
240+
tasks = aio.BoundedTaskSet(2)
241+
await tasks.drain()
242+
tasks.stop()
238243

239244

240245
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)