You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

355 lines
12 KiB

"""
Test for async dependencies with cleanup (yield) and BackgroundTasks interaction.
This tests the scenario where dependencies with cleanup logic (yield) may behave
inconsistently when used inside BackgroundTasks under different scope settings.
The key issue:
- Dependencies with scope="function" have cleanup run BEFORE BackgroundTasks execute
- Dependencies with scope="request" (default) have cleanup run AFTER BackgroundTasks execute
This can lead to inconsistent behavior where a resource is cleaned up before
a background task that depends on it has a chance to run.
"""
import asyncio
from typing import Any
import pytest
from fastapi import BackgroundTasks, Depends, FastAPI
from fastapi.testclient import TestClient
# Track cleanup and background task execution order
execution_log: list[str] = []
def reset_log():
execution_log.clear()
# Test 1: Default scope (request) - cleanup should happen AFTER background task
def test_background_task_with_request_scope_yield_dependency():
"""
With default (request) scope, dependency cleanup should happen AFTER
the background task completes.
"""
reset_log()
app = FastAPI()
resource_value = {"status": "initialized"}
async def get_resource():
execution_log.append("resource_setup")
resource_value["status"] = "active"
yield resource_value
execution_log.append("resource_cleanup")
resource_value["status"] = "cleaned_up"
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
resource: dict = Depends(get_resource),
):
async def bg_task(res: dict):
execution_log.append(f"bg_task_start:status={res['status']}")
await asyncio.sleep(0.01) # Simulate async work
execution_log.append(f"bg_task_end:status={res['status']}")
background_tasks.add_task(bg_task, resource)
return {"status": resource["status"]}
client = TestClient(app)
response = client.get("/test")
assert response.status_code == 200
assert response.json() == {"status": "active"}
# Verify order: setup -> bg_task -> cleanup
assert execution_log == [
"resource_setup",
"bg_task_start:status=active",
"bg_task_end:status=active",
"resource_cleanup",
]
# Test 2: Function scope - cleanup happens BEFORE background task (BUG!)
def test_background_task_with_function_scope_yield_dependency():
"""
With scope="function", dependency cleanup happens BEFORE the background task,
which is inconsistent and potentially problematic.
This test demonstrates the issue: the resource is cleaned up before
the background task runs, so the background task sees the cleaned up state.
"""
reset_log()
app = FastAPI()
resource_value = {"status": "initialized"}
async def get_resource():
execution_log.append("resource_setup")
resource_value["status"] = "active"
yield resource_value
execution_log.append("resource_cleanup")
resource_value["status"] = "cleaned_up"
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
resource: dict = Depends(get_resource, scope="function"),
):
async def bg_task(res: dict):
execution_log.append(f"bg_task_start:status={res['status']}")
await asyncio.sleep(0.01)
execution_log.append(f"bg_task_end:status={res['status']}")
background_tasks.add_task(bg_task, resource)
return {"status": resource["status"]}
client = TestClient(app)
response = client.get("/test")
assert response.status_code == 200
assert response.json() == {"status": "active"}
# With function scope, cleanup happens BEFORE background task runs
# This is the inconsistent behavior!
assert execution_log == [
"resource_setup",
"resource_cleanup", # Cleanup happens first!
"bg_task_start:status=cleaned_up", # BG task sees cleaned up state
"bg_task_end:status=cleaned_up",
]
# Test 3: Database-like connection scenario with function scope
def test_database_connection_closed_before_background_task():
"""
Simulates a real-world scenario where a database connection is closed
before a background task that needs it runs.
"""
reset_log()
app = FastAPI()
class FakeDBConnection:
def __init__(self):
self.is_open = False
self.data = []
def open(self):
self.is_open = True
execution_log.append("db_opened")
def close(self):
self.is_open = False
execution_log.append("db_closed")
def query(self):
if not self.is_open:
execution_log.append("db_query_failed:connection_closed")
raise RuntimeError("Connection closed")
execution_log.append("db_query_success")
return {"result": "data"}
db = FakeDBConnection()
async def get_db():
db.open()
yield db
db.close()
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
conn: FakeDBConnection = Depends(get_db, scope="function"),
):
async def bg_task(connection: FakeDBConnection):
try:
result = connection.query()
execution_log.append(f"bg_task_got_result")
except RuntimeError as e:
execution_log.append(f"bg_task_error:{e}")
background_tasks.add_task(bg_task, conn)
return {"status": "task_queued"}
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/test")
assert response.status_code == 200
# The background task fails because connection was closed before it ran
assert "db_closed" in execution_log
assert execution_log.index("db_closed") < execution_log.index("db_query_failed:connection_closed")
# Test 4: Nested dependencies with mixed scopes
def test_nested_dependencies_mixed_scopes():
"""
Test nested dependencies where outer has request scope and inner has function scope.
"""
reset_log()
app = FastAPI()
async def outer_resource():
execution_log.append("outer_setup")
yield "outer_active"
execution_log.append("outer_cleanup")
async def inner_resource(outer: str = Depends(outer_resource)):
execution_log.append(f"inner_setup:outer={outer}")
yield f"inner_active:outer={outer}"
execution_log.append("inner_cleanup")
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
inner: str = Depends(inner_resource, scope="function"),
):
async def bg_task(value: str):
execution_log.append(f"bg_task:{value}")
background_tasks.add_task(bg_task, inner)
return {"value": inner}
client = TestClient(app)
response = client.get("/test")
assert response.status_code == 200
# Check execution order - inner cleanup should happen before outer
# and before background task due to function scope
print("Execution log:", execution_log)
assert "inner_cleanup" in execution_log
assert "outer_cleanup" in execution_log
# Test 5: Multiple background tasks with function scope dependency
def test_multiple_background_tasks_function_scope():
"""
Multiple background tasks all trying to use a function-scoped dependency.
"""
reset_log()
app = FastAPI()
counter = {"value": 0}
async def get_counter():
counter["value"] = 1
execution_log.append("counter_setup")
yield counter
execution_log.append("counter_cleanup")
counter["value"] = 0
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
cnt: dict = Depends(get_counter, scope="function"),
):
async def increment(c: dict):
execution_log.append(f"increment:value={c['value']}")
c["value"] += 1
background_tasks.add_task(increment, cnt)
background_tasks.add_task(increment, cnt)
background_tasks.add_task(increment, cnt)
return {"initial_value": cnt["value"]}
client = TestClient(app)
response = client.get("/test")
assert response.status_code == 200
assert response.json() == {"initial_value": 1}
# Check that cleanup happened before background tasks ran
cleanup_idx = execution_log.index("counter_cleanup")
increment_indices = [
i for i, log in enumerate(execution_log) if log.startswith("increment")
]
# With function scope, cleanup happens before all increments
assert all(cleanup_idx < idx for idx in increment_indices)
# Test 6: Async workload with concurrent requests
def test_concurrent_requests_with_yield_dependencies():
"""
Test behavior under concurrent async workload.
"""
reset_log()
app = FastAPI()
shared_state = {"active_connections": 0, "max_connections": 0}
async def get_connection():
shared_state["active_connections"] += 1
shared_state["max_connections"] = max(
shared_state["max_connections"], shared_state["active_connections"]
)
execution_log.append(f"conn_open:active={shared_state['active_connections']}")
yield shared_state["active_connections"]
shared_state["active_connections"] -= 1
execution_log.append(f"conn_close:active={shared_state['active_connections']}")
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
conn_id: int = Depends(get_connection, scope="function"),
):
async def bg_task(id: int):
await asyncio.sleep(0.01)
execution_log.append(f"bg_task:conn_id={id}:active={shared_state['active_connections']}")
background_tasks.add_task(bg_task, conn_id)
return {"conn_id": conn_id}
client = TestClient(app)
# Make a single request first
response = client.get("/test")
assert response.status_code == 200
# Verify that connection was closed before background task ran
# (due to function scope)
conn_close_indices = [
i for i, log in enumerate(execution_log) if log.startswith("conn_close")
]
bg_task_indices = [
i for i, log in enumerate(execution_log) if log.startswith("bg_task")
]
if conn_close_indices and bg_task_indices:
# Function scope: close happens before bg_task
assert conn_close_indices[0] < bg_task_indices[0]
# Test 7: Verify request scope works correctly (control test)
def test_request_scope_preserves_resource_for_background_task():
"""
Control test: request scope should keep resource active during background task.
"""
reset_log()
app = FastAPI()
resource = {"is_active": False}
async def get_resource():
resource["is_active"] = True
execution_log.append("resource_activated")
yield resource
resource["is_active"] = False
execution_log.append("resource_deactivated")
@app.get("/test")
async def endpoint(
background_tasks: BackgroundTasks,
res: dict = Depends(get_resource), # Default request scope
):
async def bg_task(r: dict):
execution_log.append(f"bg_task:is_active={r['is_active']}")
background_tasks.add_task(bg_task, res)
return {"is_active": res["is_active"]}
client = TestClient(app)
response = client.get("/test")
assert response.status_code == 200
# Background task should see is_active=True because cleanup happens after
assert "bg_task:is_active=True" in execution_log
if __name__ == "__main__":
pytest.main([__file__, "-v"])