Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/scripts/scan_async_blockers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python3

Check warning on line 1 in backend/scripts/scan_async_blockers.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/scripts/scan_async_blockers.py is 969 lines; consider splitting files over 800 lines.
"""
FastAPI async blocker scanner for backend/.

Expand Down Expand Up @@ -268,6 +268,19 @@
return calls


def _awaited_call_ids(node: FunctionNode) -> Set[int]:
"""Identities of the Call nodes that are the direct operand of an `await`.

Keyed on identity rather than line number: one line can hold both an awaited call and a
synchronous one, and only the awaited half is safe.
"""
awaited: Set[int] = set()
for child in ast.walk(node):
if isinstance(child, ast.Await) and isinstance(child.value, ast.Call):
awaited.add(id(child.value))
return awaited


def _scan_function_body(
node: FunctionNode,
db_names: Set[str],
Expand All @@ -285,13 +298,20 @@

offloaded = _get_offloaded_lines(node)
nested = _collect_nested_func_lines(node)
awaited = _awaited_call_ids(node)

for child in _walk_body(node):
if not isinstance(child, ast.Call):
continue
line = child.lineno
if line in offloaded or line in nested:
continue
# `await f()` yields to the event loop; it is the correct way to call an async
# helper, including the async accessors in `database.*`. Counting it as blocking
# made a correct fix unpushable and offered no way to say so — there is no
# allowlist or inline waiver in this scanner.
if id(child) in awaited:
continue
body_call_lines.add(line)
if isinstance(child.func, ast.Name):
if child.func.id in db_names:
Expand Down
40 changes: 40 additions & 0 deletions backend/tests/unit/test_scan_async_blockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,3 +550,43 @@ async def use_context():
results = scanner.scan_dirs([str(source_path)])

assert results["no_await_should_be_def"] == []


def test_awaited_async_db_accessor_is_not_blocking(scanner, tmp_path):
"""`await`ing an async accessor from database.* yields to the loop, so it is not blocking.

The scanner classified every `database.*` import called inside an `async def` as a sync DB
call without checking whether it was awaited. That made a correct fix — routing the
proactive dispatcher onto the publisher's shared Redis client via the async accessor —
fail the push gate, with no allowlist or inline waiver to say otherwise.
"""
_source_path, results = _scan_source(
scanner,
tmp_path,
"""
from database.redis_db import get_async_redis_client

async def dispatcher():
client = await get_async_redis_client()
return client
""",
)

assert results["async_helpers_with_blocking"] == []


def test_unawaited_sync_db_call_is_still_blocking(scanner, tmp_path):
"""The guard must not widen: the same import called without `await` still blocks."""
_source_path, results = _scan_source(
scanner,
tmp_path,
"""
from database.redis_db import get_redis_client

async def dispatcher():
client = get_redis_client()
return client
""",
)

assert len(results["async_helpers_with_blocking"]) == 1
Loading