windlass.core.concurrency¶
concurrency
¶
Async/sync bridging, bounded parallelism and batching helpers.
Every Windlass interface is async-first: adapters implement a* coroutines
and the base classes derive the blocking variants from them. That gives one
implementation, two APIs, and no risk of the two drifting apart.
The tricky part is calling async code from sync code safely. asyncio.run
explodes inside a running event loop (Jupyter, FastAPI, LangServe), so
:func:run_sync detects that case and hands the coroutine to a dedicated
background loop thread instead.
shutdown_background_loop
¶
Tear down the background event loop used by :func:run_sync.
Called automatically at interpreter exit. Exposed for tests and for hosts that want deterministic cleanup.
Source code in src\windlass\core\concurrency.py
run_sync
¶
Run an awaitable to completion from synchronous code.
Every call is dispatched to one long-lived background event loop, whether or
not the caller is already inside a loop. That is a correctness requirement,
not an optimisation — the same reasoning as :func:iter_sync.
:func:asyncio.run creates a loop and closes it again on every call. Any
resource bound to that loop dies with it, and the most common such resource
is a connection pool: every provider that keeps an httpx.AsyncClient
alive between calls (Ollama, and every vendor SDK underneath OpenAI,
Anthropic, Groq and Gemini) pools keep-alive sockets on the loop that opened
them. With a fresh loop per call, the first blocking call succeeds and the
second raises RuntimeError: Event loop is closed from deep inside httpx
— a failure that mock transports never reproduce, because they open no
sockets.
Sharing one loop also removes the per-call cost of standing a loop up and tearing it down, which dominates short calls.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
awaitable
|
Awaitable[_T]
|
The coroutine or future to run. |
required |
timeout
|
float | None
|
Optional ceiling in seconds. |
None
|
Returns:
| Type | Description |
|---|---|
_T
|
The awaited result. |
Raises:
| Type | Description |
|---|---|
TimeoutError
|
If |
RuntimeError
|
If called from inside the background loop itself, where blocking on it could never return. |
Exception
|
Anything the awaitable raises, re-raised unchanged. |
Note
Calling this from inside a coroutine works but blocks the calling
thread. Prefer await on the a* method when you are already async.
Example
async def work() -> int: ... return 42 run_sync(work()) 42
Repeated calls share a loop, so a pooled client survives between them:
from windlass.providers.llm.fake import FakeLLM llm = FakeLLM(responses=["one", "two"]) llm.complete("a").content, llm.complete("b").content ('one', 'two')
Source code in src\windlass\core\concurrency.py
iter_sync
¶
Consume an async iterator from synchronous code.
Pulls one item at a time, so streaming stays streaming — nothing is buffered ahead of the consumer.
Every __anext__ is dispatched to the same background event loop. That
is not an optimisation, it is a correctness requirement: :func:asyncio.run
calls loop.shutdown_asyncgens() on exit, which finalises any suspended
async generator. Driving one with repeated asyncio.run calls therefore
yields exactly one item and then silently stops.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agen
|
AsyncIterator[_T]
|
The async iterator to drain. |
required |
timeout
|
float | None
|
Optional per-item ceiling in seconds. |
None
|
Yields:
| Type | Description |
|---|---|
_T
|
Each item produced by |
Raises:
| Type | Description |
|---|---|
TimeoutError
|
If |
Exception
|
Anything the iterator raises, re-raised unchanged. |
Example
async def gen(): ... for i in range(3): ... yield i list(iter_sync(gen())) [0, 1, 2]
Source code in src\windlass\core\concurrency.py
gather_bounded
async
¶
gather_bounded(
awaitables: Sequence[Awaitable[_T]],
*,
limit: int = 8,
return_exceptions: bool = False
) -> list[_T]
asyncio.gather with a concurrency ceiling.
Unbounded gather over a few thousand embedding calls will happily open a
few thousand sockets and get you rate limited. This keeps at most limit
in flight while preserving input order in the results.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
awaitables
|
Sequence[Awaitable[_T]]
|
The coroutines to run. |
required |
limit
|
int
|
Maximum number in flight at once. |
8
|
return_exceptions
|
bool
|
When True, failures land in the result list instead of propagating. |
False
|
Returns:
| Type | Description |
|---|---|
list[_T]
|
Results in the same order as |
Example
import asyncio async def double(x): return x * 2 asyncio.run(gather_bounded([double(i) for i in range(4)], limit=2)) [0, 2, 4, 6]
Source code in src\windlass\core\concurrency.py
map_async
async
¶
map_async(
fn: Callable[[_T], Awaitable[_R]],
items: Sequence[_T],
*,
limit: int = 8,
return_exceptions: bool = False
) -> list[_R]
Apply an async function across items with bounded concurrency.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
Callable[[_T], Awaitable[_R]]
|
Async callable applied to each item. |
required |
items
|
Sequence[_T]
|
Inputs. |
required |
limit
|
int
|
Maximum concurrent invocations. |
8
|
return_exceptions
|
bool
|
Collect failures instead of raising. |
False
|
Returns:
| Type | Description |
|---|---|
list[_R]
|
Results in input order. |
Example
import asyncio async def square(x): return x * x asyncio.run(map_async(square, [1, 2, 3], limit=2)) [1, 4, 9]
Source code in src\windlass\core\concurrency.py
batched
¶
Split an iterable into lists of at most size elements.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
items
|
Iterable[_T]
|
Any iterable; consumed lazily. |
required |
size
|
int
|
Maximum batch size. Must be positive. |
required |
Yields:
| Type | Description |
|---|---|
list[_T]
|
Successive batches. The final batch may be shorter. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Example
list(batched(range(5), 2)) [[0, 1], [2, 3], [4]]
Source code in src\windlass\core\concurrency.py
to_thread
async
¶
Run a blocking callable on the default thread pool.
Adapters around synchronous SDKs (FAISS, sentence-transformers, pypdf) use this so they never block the event loop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
Callable[..., _R]
|
The blocking callable. |
required |
*args
|
Any
|
Positional arguments. |
()
|
**kwargs
|
Any
|
Keyword arguments. |
{}
|
Returns:
| Type | Description |
|---|---|
_R
|
Whatever |
Example
import asyncio asyncio.run(to_thread(sum, [1, 2, 3])) 6