Skip to content

Commit d4d8e65

Browse files
authored
♻️ Adding lifespan support for FastAPI & migrated dynamic-scheduler to use it (#7149)
On behave of @neagu
1 parent 4352166 commit d4d8e65

File tree

53 files changed

+853
-269
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+853
-269
lines changed

packages/service-library/requirements/_fastapi.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88

99
fastapi
10+
fastapi-lifespan-manager
1011
httpx
1112
opentelemetry-instrumentation-fastapi
1213
opentelemetry-instrumentation-httpx

packages/service-library/requirements/_fastapi.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ deprecated==1.2.17
2323
# opentelemetry-api
2424
# opentelemetry-semantic-conventions
2525
fastapi==0.115.7
26+
# via
27+
# -r requirements/_fastapi.in
28+
# fastapi-lifespan-manager
29+
fastapi-lifespan-manager==0.1.4
2630
# via -r requirements/_fastapi.in
2731
h11==0.14.0
2832
# via
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from collections.abc import AsyncIterator
2+
from typing import Protocol
3+
4+
from fastapi import FastAPI
5+
from fastapi_lifespan_manager import LifespanManager, State
6+
7+
8+
class LifespanGenerator(Protocol):
9+
def __call__(self, app: FastAPI) -> AsyncIterator["State"]:
10+
...
11+
12+
13+
def combine_lifespans(*generators: LifespanGenerator) -> LifespanManager:
14+
15+
manager = LifespanManager()
16+
17+
for generator in generators:
18+
manager.add(generator)
19+
20+
return manager

packages/service-library/src/servicelib/fastapi/profiler_middleware.py renamed to packages/service-library/src/servicelib/fastapi/profiler.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any, Final
22

3+
from fastapi import FastAPI
34
from servicelib.aiohttp import status
45
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
56
from starlette.requests import Request
@@ -13,7 +14,7 @@
1314
)
1415

1516

16-
def is_last_response(response_headers: dict[bytes, bytes], message: dict[str, Any]):
17+
def _is_last_response(response_headers: dict[bytes, bytes], message: dict[str, Any]):
1718
if (
1819
content_type := response_headers.get(b"content-type")
1920
) and content_type == MIMETYPE_APPLICATION_JSON.encode():
@@ -79,7 +80,7 @@ async def _send_wrapper(message):
7980
response_headers = dict(message.get("headers"))
8081
message["headers"] = check_response_headers(response_headers)
8182
elif message["type"] == "http.response.body":
82-
if is_last_response(response_headers, message):
83+
if _is_last_response(response_headers, message):
8384
_profiler.stop()
8485
profile_text = _profiler.output_text(
8586
unicode=True, color=True, show_all=True
@@ -96,3 +97,8 @@ async def _send_wrapper(message):
9697

9798
finally:
9899
_profiler.reset()
100+
101+
102+
def initialize_profiler(app: FastAPI) -> None:
103+
# NOTE: this cannot be ran once the application is started
104+
app.add_middleware(ProfilerMiddleware)
Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,62 @@
11
# pylint: disable=protected-access
22

33

4+
from collections.abc import AsyncIterator
5+
46
from fastapi import FastAPI
7+
from fastapi_lifespan_manager import State
58
from prometheus_client import CollectorRegistry
69
from prometheus_fastapi_instrumentator import Instrumentator
710

811

9-
def setup_prometheus_instrumentation(app: FastAPI) -> Instrumentator:
12+
def initialize_prometheus_instrumentation(app: FastAPI) -> None:
13+
# NOTE: this cannot be ran once the application is started
14+
1015
# NOTE: use that registry to prevent having a global one
1116
app.state.prometheus_registry = registry = CollectorRegistry(auto_describe=True)
12-
instrumentator = Instrumentator(
17+
app.state.prometheus_instrumentator = Instrumentator(
1318
should_instrument_requests_inprogress=False, # bug in https://github.com/trallnag/prometheus-fastapi-instrumentator/issues/317
1419
inprogress_labels=False,
1520
registry=registry,
16-
).instrument(app)
21+
)
22+
app.state.prometheus_instrumentator.instrument(app)
23+
24+
25+
def _startup(app: FastAPI) -> None:
26+
assert isinstance(app.state.prometheus_instrumentator, Instrumentator) # nosec
27+
app.state.prometheus_instrumentator.expose(app, include_in_schema=False)
28+
29+
30+
def _shutdown(app: FastAPI) -> None:
31+
assert isinstance(app.state.prometheus_registry, CollectorRegistry) # nosec
32+
registry = app.state.prometheus_registry
33+
for collector in list(registry._collector_to_names.keys()): # noqa: SLF001
34+
registry.unregister(collector)
35+
36+
37+
def get_prometheus_instrumentator(app: FastAPI) -> Instrumentator:
38+
assert isinstance(app.state.prometheus_instrumentator, Instrumentator) # nosec
39+
return app.state.prometheus_instrumentator
40+
41+
42+
def setup_prometheus_instrumentation(app: FastAPI) -> Instrumentator:
43+
initialize_prometheus_instrumentation(app)
1744

1845
async def _on_startup() -> None:
19-
instrumentator.expose(app, include_in_schema=False)
46+
_startup(app)
2047

21-
def _unregister() -> None:
22-
# NOTE: avoid registering collectors multiple times when running unittests consecutively (https://stackoverflow.com/a/62489287)
23-
for collector in list(registry._collector_to_names.keys()): # noqa: SLF001
24-
registry.unregister(collector)
48+
def _on_shutdown() -> None:
49+
_shutdown(app)
2550

2651
app.add_event_handler("startup", _on_startup)
27-
app.add_event_handler("shutdown", _unregister)
28-
return instrumentator
52+
app.add_event_handler("shutdown", _on_shutdown)
53+
54+
return get_prometheus_instrumentator(app)
55+
56+
57+
async def lifespan_prometheus_instrumentation(app: FastAPI) -> AsyncIterator[State]:
58+
# NOTE: requires ``initialize_prometheus_instrumentation`` to be called before the
59+
# lifespan of the applicaiton runs, usually rigth after the ``FastAPI`` instance is created
60+
_startup(app)
61+
yield {}
62+
_shutdown(app)

packages/service-library/src/servicelib/fastapi/tracing.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
"""
44

55
import logging
6+
from collections.abc import AsyncIterator
67

78
from fastapi import FastAPI
9+
from fastapi_lifespan_manager import State
810
from httpx import AsyncClient, Client
911
from opentelemetry import trace
1012
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
@@ -15,6 +17,7 @@
1517
from opentelemetry.sdk.resources import Resource
1618
from opentelemetry.sdk.trace import TracerProvider
1719
from opentelemetry.sdk.trace.export import BatchSpanProcessor
20+
from servicelib.fastapi.lifespan_utils import LifespanGenerator
1821
from servicelib.logging_utils import log_context
1922
from settings_library.tracing import TracingSettings
2023
from yarl import URL
@@ -131,5 +134,15 @@ def setup_tracing(
131134
RequestsInstrumentor().instrument()
132135

133136

137+
def get_lifespan_tracing(
138+
tracing_settings: TracingSettings, service_name: str
139+
) -> LifespanGenerator:
140+
async def _(app: FastAPI) -> AsyncIterator[State]:
141+
setup_tracing(app, tracing_settings, service_name)
142+
yield {}
143+
144+
return _
145+
146+
134147
def setup_httpx_client_tracing(client: AsyncClient | Client):
135148
HTTPXClientInstrumentor.instrument_client(client)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from collections.abc import AsyncIterator
2+
3+
import asgi_lifespan
4+
import pytest
5+
from fastapi import FastAPI
6+
from fastapi_lifespan_manager import State
7+
from servicelib.fastapi.lifespan_utils import combine_lifespans
8+
9+
10+
async def test_multiple_lifespan_managers(capsys: pytest.CaptureFixture):
11+
async def database_lifespan(app: FastAPI) -> AsyncIterator[State]:
12+
_ = app
13+
print("setup DB")
14+
yield {}
15+
print("shutdown DB")
16+
17+
async def cache_lifespan(app: FastAPI) -> AsyncIterator[State]:
18+
_ = app
19+
print("setup CACHE")
20+
yield {}
21+
print("shutdown CACHE")
22+
23+
app = FastAPI(lifespan=combine_lifespans(database_lifespan, cache_lifespan))
24+
25+
capsys.readouterr()
26+
27+
async with asgi_lifespan.LifespanManager(app):
28+
messages = capsys.readouterr().out
29+
30+
assert "setup DB" in messages
31+
assert "setup CACHE" in messages
32+
assert "shutdown DB" not in messages
33+
assert "shutdown CACHE" not in messages
34+
35+
messages = capsys.readouterr().out
36+
37+
assert "setup DB" not in messages
38+
assert "setup CACHE" not in messages
39+
assert "shutdown DB" in messages
40+
assert "shutdown CACHE" in messages

services/agent/requirements/_base.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ fastapi==0.115.5
9292
# via
9393
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
9494
# -r requirements/_base.in
95+
# fastapi-lifespan-manager
96+
fastapi-lifespan-manager==0.1.4
97+
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
9598
faststream==0.5.31
9699
# via -r requirements/../../../packages/service-library/requirements/_base.in
97100
frozenlist==1.5.0
@@ -349,6 +352,18 @@ redis==5.2.1
349352
# -r requirements/../../../packages/service-library/requirements/_base.in
350353
referencing==0.35.1
351354
# via
355+
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
356+
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
357+
# -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
358+
# -c requirements/../../../packages/service-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
359+
# -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
360+
# -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
361+
# -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
362+
# -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
363+
# -c requirements/../../../packages/service-library/requirements/../../../requirements/constraints.txt
364+
# -c requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
365+
# -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
366+
# -c requirements/../../../requirements/constraints.txt
352367
# jsonschema
353368
# jsonschema-specifications
354369
requests==2.32.3

services/api-server/requirements/_base.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,11 @@ fastapi==0.115.6
187187
# via
188188
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
189189
# -r requirements/_base.in
190+
# fastapi-lifespan-manager
190191
fastapi-cli==0.0.6
191192
# via fastapi
193+
fastapi-lifespan-manager==0.1.4
194+
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
192195
fastapi-pagination==0.12.32
193196
# via -r requirements/_base.in
194197
faststream==0.5.33
@@ -249,7 +252,6 @@ httpx==0.27.2
249252
# -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
250253
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
251254
# -c requirements/../../../requirements/constraints.txt
252-
# -c requirements/./constraints.txt
253255
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
254256
# -r requirements/_base.in
255257
# fastapi

services/api-server/src/simcore_service_api_server/core/application.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from fastapi_pagination import add_pagination
55
from models_library.basic_types import BootModeEnum
66
from packaging.version import Version
7-
from servicelib.fastapi.profiler_middleware import ProfilerMiddleware
7+
from servicelib.fastapi.profiler import initialize_profiler
88
from servicelib.fastapi.tracing import setup_tracing
99
from servicelib.logging_utils import config_all_loggers
1010

@@ -123,7 +123,7 @@ def init_app(settings: ApplicationSettings | None = None) -> FastAPI:
123123
)
124124

125125
if settings.API_SERVER_PROFILING:
126-
app.add_middleware(ProfilerMiddleware)
126+
initialize_profiler(app)
127127

128128
if app.state.settings.API_SERVER_PROMETHEUS_INSTRUMENTATION_ENABLED:
129129
setup_prometheus_instrumentation(app)

0 commit comments

Comments
 (0)