FastAPI co-hosting, OpenTelemetry tracing, and per-endpoint web feature flags - #748
Open
wbarnha wants to merge 11 commits into
Open
FastAPI co-hosting, OpenTelemetry tracing, and per-endpoint web feature flags#748wbarnha wants to merge 11 commits into
wbarnha wants to merge 11 commits into
Conversation
Faust apps are declared at module scope but, under an ASGI server such as uvicorn, started from a loop created later by asyncio.run(). Several call sites resolved app.loop at *declaration* time, which pinned the App to whichever loop mode's get_event_loop() found at import -- a loop that is never run. Every service built during startup then inherited that dead loop and the worker failed with "Please create objects with the same loop as running with" or "Task ... got Future ... attached to a different loop". faust worker survived this by accident: faust/cli/base.py reuses the same policy loop mode already grabbed, so the pinned loop happened to be the right one. asyncio.run() creates a different loop, so the pin became fatal. Stop resolving the loop eagerly and let mode.Service late-bind it on first access, which happens inside _default_start() -- i.e. in the loop that actually runs the app: - Agent, Collection, Web and livecheck Case no longer pass loop=app.loop to Service.__init__. - App._new_transport()/_new_producer_transport() no longer pass loop=, and Transport.loop is now resolved lazily on first access (it stays settable, and an explicit loop= argument still wins). - Transport.create_conductor() no longer passes loop=, since app.topics builds the conductor at import time when an agent is declared. - App.tables no longer passes loop= to the table manager. Adds tests/functional/test_fastapi_cohost.py, which reproduces the import-time-vs-running-loop split without a broker by declaring the app in a synchronous fixture and asserting from async tests. Constructing a Transport performs no I/O, so the exact invariant that broke can be asserted directly. Fixes #322 Fixes #435 Fixes #448 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Running FastAPI and Faust in one process previously meant hand-rolling a
lifespan handler, and the two shipped examples both carried a "this doesn't
work yet" caveat. This adds a supported way to do it, in both directions.
The ASGI server drives:
api = FastAPI(lifespan=faust_lifespan(faust_app))
@api.post("/greet")
async def greet(text: str):
await greetings.send(value=text)
return {"ok": True}
or the Faust worker drives, serving your ASGI app on its own loop:
serve_asgi(faust_app, api, port=8000)
Public API: faust_lifespan(), the composable faust_app_running() context
manager, the lower-level bind_to_running_loop(), AsgiService and
serve_asgi(). bind_to_running_loop() reads app._loop rather than app.loop,
since the public property resolves and caches a loop as a side effect of
being read -- exactly what it is trying to detect -- and raises LoopMismatch
with a message naming the usual causes.
Details worth knowing:
- faust_app_running() uses maybe_start(), so it composes with an app that is
already running and will not stop one it did not start.
- faust_lifespan() yields None: Starlette merges a non-None lifespan value
into the ASGI scope as state.
- serve_asgi() registers via the public App.service() decorator, so the
server starts after table recovery -- when it is actually safe to serve.
- The module imports nothing from fastapi or starlette, so it works with
Starlette, Quart and Litestar too; uvicorn is imported lazily.
- uvicorn's signal handling is neutralized so mode.Worker keeps owning
SIGINT/SIGTERM. The hook moved in uvicorn 0.27, so both the old
install_signal_handlers() and the new capture_signals() are handled.
Packaging: adds the faust[fastapi] extra, and fixes faust[aerospike], which
shipped requirements/extras/aerospike.txt without the matching BUNDLES entry
and so resolved to nothing despite being advertised in the README.
tests/unit/test_packaging.py now guards both directions of that mapping.
Tests bind no socket and start no uvicorn, so they stay fast and keep the
autouse lingering-thread/task guards happy. The integration test drives a
real FastAPI app in-process through httpx.ASGITransport and is skipped
unless the extra is installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
opentelemetry-instrumentation-aiokafka already wraps AIOKafkaProducer.send
and AIOKafkaConsumer.getmany, which is exactly what Faust's aiokafka driver
calls, so most of a distributed trace already works:
FastAPI server span
-> aiokafka "{topic} send" (PRODUCER, injects traceparent)
-> [Kafka]
-> aiokafka "{topic} receive" (CONSUMER, extracts traceparent)
-> ???
The last hop is the one nobody outside Faust can supply. The consumer runs
in its own thread (ConsumerThread) and contextvars never cross threads, so
the receive span is opened *and closed* inside getmany, on a thread the agent
never runs on. The result is an orphaned receive span and an unparented
agent, which reads worse in a trace viewer than no instrumentation at all.
OpenTelemetrySensor closes it. It extracts trace context from the Kafka
message headers and opens a "{topic} process" CONSUMER span that stays
current for exactly as long as the stream is processing the event, so
anything the agent does nests underneath it:
from faust.contrib.opentelemetry import setup_opentelemetry
setup_opentelemetry(app)
The FastAPI side is instrumented automatically. faust_lifespan() and
AsgiService attach FastAPIInstrumentor when opentelemetry is installed *and*
a real TracerProvider has been configured -- until an SDK is configured the
OpenTelemetry API is a no-op, so this never enables telemetry nobody asked
for. Pass opentelemetry=False to opt out, or True to force. Apps already
carrying _is_instrumented_by_opentelemetry (e.g. started under
opentelemetry-instrument) are left alone rather than double-wrapped.
Deliberate choices:
- Depends on opentelemetry-api only, and never calls set_tracer_provider():
configuring the SDK is the application's job, not a library's.
- Reads trace context from headers, never writes it. The aiokafka
instrumentation already injects on produce and its setter appends
unconditionally, so a second injector would put two traceparent headers on
the wire.
- Warns when the opentracing TracingSensor is already registered, since that
one does inject and running both produces duplicate headers.
- Span naming follows the Python contrib convention ("{topic} process")
rather than the spec's "{operation} {destination}", so Faust and aiokafka
spans stay consistent in one backend.
- Every OpenTelemetry call is wrapped: telemetry must never break message
processing.
Attributes follow the messaging semantic conventions, including omitting
messaging.kafka.message.key when the key is null as the spec requires.
Adds the faust[opentelemetry] extra. requirements/test.txt gets the API and
SDK only -- both pure Python -- so the sensor is covered in CI without
pulling fastapi into all 15 matrix legs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Adds docs/userguide/fastapi.rst, the first userguide page for any of this. It covers the one-process-one-loop rule and why violating it produces the errors in #448, composing with your own lifespan, running under faust worker, how Faust's own aiohttp server relates to yours, and the OpenTelemetry setup. The troubleshooting section quotes the literal error strings so searching for them lands here. Also adds reference stubs for both new contrib modules and extras blurbs in the installation docs. Both examples are rewritten onto faust.contrib.fastapi, and the "You MUST have app defined ... but this doesn't work yet" comment is gone -- that caveat was a name collision, not a missing feature. `faust -A` looks for an attribute named `app`, and the examples had bound that name to the FastAPI object; binding it to the Faust app and calling the API `api` is all it took. The examples now say so, since the FastAPI convention of naming the application `app` is exactly what breaks it. examples/fastapi/ is renamed to examples/fastapi_project/. As a package directory named `fastapi`, it shadowed the real fastapi distribution whenever examples/ was on sys.path -- so the documented `uvicorn fastapi_example:api` died with "cannot import name 'FastAPI' from 'fastapi'". Both examples now run as documented. Also adds examples/fastapi_project/worker_main.py showing the same API served from inside `faust worker` via serve_asgi(), and de-stacks @faust_app.timer from @router.get -- stacking them registers the undecorated function as the route and the timer-wrapped one as the timer, which is rarely what is meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #748 +/- ##
==========================================
+ Coverage 95.97% 96.07% +0.10%
==========================================
Files 103 105 +2
Lines 11072 11326 +254
Branches 1191 1226 +35
==========================================
+ Hits 10626 10882 +256
+ Misses 352 350 -2
Partials 94 94 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Testing against real SIGINTs showed the comment on _disable_signal_handling was wrong. It claimed that uvicorn's handlers "win" because they are installed later, so Ctrl-C would stop only the web server while the worker kept running. That is not what happens. mode.Worker registers SIGINT/SIGTERM through loop.add_signal_handler(), which on Unix is delivered via asyncio's wakeup file descriptor. uvicorn installs its handlers with signal.signal(), which does replace the OS-level handler -- but not the wakeup fd, so both still fire. Verified directly: after signal.signal() displaces asyncio's _sighandler_noop, a SIGINT still reaches the asyncio callback. Measured end to end with a mode.Worker hosting an AsgiService on a real port, signalling the process group the way a terminal does. With the override and without it, single Ctrl-C and double Ctrl-C, all four combinations exit 0, run the ASGI on_stop, and complete a slow sibling service's drain. The only observable difference is that without the override uvicorn logs a duplicate interrupt. So the override is not load-bearing for graceful shutdown. It is still worth keeping -- it avoids a second concurrent shutdown path, and stops uvicorn setting force_exit on a repeated signal while the worker is still draining -- but the docstring now says that rather than something untrue. Also adds a test that _create_server() actually applies the override to a real uvicorn.Server. The existing tests exercised _disable_signal_handling in isolation with mocks, which would not have caught _create_server forgetting to call it; the new test fails when that call is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Two CI failures on this branch.
1. Python 3.14 (both Cython legs): every tests/integration/cli test failed
with
faust/cli/base.py:625, in run_using_worker
loop = asyncio.get_event_loop_policy().get_event_loop()
RuntimeError: There is no current event loop in thread 'MainThread'.
This is a regression from the loop-binding change, and a good example of
why that change needed care. Previously an eager app.loop read at import
time went through mode's get_event_loop(), which creates a loop *and*
calls set_event_loop(). Removing those reads means nothing sets a current
loop before the CLI runs, and Python 3.14 removed the get-or-create
fallback from the policy, so the call now raises. 3.10-3.13 still
auto-create, which is why only the 3.14 legs went red.
Route the three call sites in faust/cli/base.py through
mode.utils.loops.get_event_loop(), which keeps get-or-create behaviour
across versions -- the same helper Transport.loop already uses. The
docstring example in faust/worker.py gets the same treatment.
The new test reproduces this on any Python by clearing the current loop,
and fails when the fix is reverted. It restores both asyncio's current
loop and mode's thread-local cache afterwards, and closes the loop it
causes to be created: leaving a non-running loop in mode's cache hangs
every later test that resolves one.
2. codecov patch coverage. The bulk of the uncovered diff was
AsgiService._create_server(), which needs uvicorn. uvicorn's only
dependencies are click (already required by faust) and h11, both pure
Python, so it is cheap enough to add to requirements/test.txt -- unlike
fastapi, which pulls in pydantic-core and has no wheel for every matrix
leg. Adds tests for the remaining reachable gaps: the stop_timeout branch
of faust_app_running(), a lifespan invoked without an ASGI app argument,
and non-bytes Kafka header values.
What stays uncovered in CI is instrument_asgi_app()'s body, which cannot
run without opentelemetry-instrumentation-fastapi. It is covered locally
with the extra installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
…dency Codecov settled at 93.44% patch coverage once all 16 matrix uploads landed (the earlier 2.45%/31.90% readings were a partially-uploaded run). The one remaining gap was instrument_asgi_app(), which CI cannot reach because opentelemetry-instrumentation-fastapi is deliberately not installed there -- it pulls in fastapi and pydantic-core, which has no wheel for every leg. Rather than add that dependency, stub the instrumentation module through sys.modules. The tests then exercise our own branching everywhere, including CI: package missing, instrumented successfully, the TypeError retry for releases predating exclude_spans, that retry also failing, and a generic failure. Those last two were marked "pragma: no cover"; they are reachable now, so the pragmas are gone. Also covers three branch partials: a sensor state carrying neither span nor token, and a stream that exposes no app (so no consumer group name). faust/contrib/fastapi.py and faust/contrib/opentelemetry.py are now at 100% statement and branch coverage, verified both with the instrumentation package installed and with it blocked to simulate CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Two related additions to the web layer.
Feature flags
-------------
Which built-in endpoints get served was decided entirely by `debug`: it
enabled the statistics and graph endpoints together, or neither, and
`/router` and `/table` were mounted unconditionally with no way to turn them
off. That last part matters, because `/table` serves table *data* over HTTP,
and `/router` publishes the URL of every other worker in the cluster.
Five settings now control them individually:
web_stats_enabled default_alias="debug" / and /assignment/
web_graph_enabled default_alias="debug" /graph
web_router_enabled default=True /router
web_tables_enabled default=True /table
web_metrics_enabled default=False /performance/
`default_alias="debug"` means an unset flag resolves to `conf.debug` when
read, so an app that sets none of these serves exactly what it served before
-- verified for both the debug and non-debug cases. `/router` and `/table`
keep defaulting to on because disabling `/router` breaks `@table_route`
across nodes; they are simply switchable now.
Blueprint selection moves into `Web._enabled_blueprints()`, driven by a
`blueprint_flags` mapping from blueprint to setting name. Blueprints with no
entry are always enabled, so anything a subclass or user adds is unaffected.
Statistics and the production index both mount at "/", so exactly one of them
is served -- the index takes over whenever statistics are off.
Metrics endpoint
----------------
`faust/web/apps/metrics.py` serves throughput, latency, consumer lag and
table statistics as JSON. Off by default: it is a new endpoint and turning it
on should be deliberate. It needs no extra dependency and is independent of
`debug`, so it can be left on in production.
It is not a flat dump of `Monitor.asdict()`. Two things are computed here
that monitor does not provide:
- **Consumer lag**, derived from log end offsets minus offsets actually read.
Monitor tracks both but never subtracts them, and lag is the number you
usually alert on. Partitions with an unknown read offset are skipped rather
than reported as fully lagged, which would spike alerts on every restart.
- **Latency percentiles**. Monitor keeps raw deques of up to a few thousand
samples; serializing those into an HTTP response is not useful. Percentiles
use nearest-rank rather than statistics.quantiles, which raises on fewer
than two samples -- a metrics endpoint should not fail on a worker that
just started.
TP-keyed counters go through Monitor's existing `_tp_*_dict()` helpers, since
`Counter[TP]` has namedtuple keys that are not JSON-serializable.
Unrelated flake fixed in passing
--------------------------------
tests/meticulous/assignor/test_copartitioned_assignor.py failed on the PyPy
leg with hypothesis FlakyFailure: the largest generated cases took 5123ms
against a 4000ms deadline on one run and 3455ms on the next. The deadline is
raised on PyPy only, so CPython keeps the tighter bound as a performance
guard. This predates this branch and is not related to the changes above.
Note: docs/includes/settingref.txt is generated by
extra/tools/render_configuration_reference.py, but regenerating it today also
mangles every `related-command-options` entry into per-character options and
drops the OAuth2 documentation block, so the new settings are added by hand
in the generator's format instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Audited every change on this branch against master for observable impact on an existing deployment, by snapshotting seven production-shaped app configs (plain, debug, web_enabled=False, web_in_thread, custom port/host, prod-like, debug+in_thread) on both revisions and diffing settings, route tables, reverse route names, agents, tables, topics and loop identity. Result: the only difference is loop identity -- False on master, True here -- which is the bug this branch fixes. Settings, routes and registered objects are identical for every config. That audit did surface one real production-visible change, now fixed: setup.py excluded "examples" but not "examples.*", so find_packages picked up the example sub-packages and shipped them into site-packages under an ``examples`` namespace -- a name no library should claim. Renaming examples/fastapi to examples/fastapi_project therefore changed what the wheel installs. Excluding "examples.*" means no example package ships either way, so the rename is invisible to installs, and a pre-existing namespace pollution goes away. Verified the set of shipped faust.* packages is byte-identical to master. Two smaller gaps, both found by cross-checking the design against the source: - The typed Settings.__init__ stub was missing the five new settings. It is never executed (_init_subclass_settings replaces __init__) but the file keeps it in sync for mypy and editors, so the settings are added there too. - docs/includes/settingref.txt documented 108 of 109 settings: web_application_options was never added when that setting landed. sphinx_celery's configcheck builder compares the two sets symmetrically under -W, so it fails on master today. Documenting it makes the difference empty. Not caught by CI because configcheck is a tox environment the workflows never invoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
Verified this branch end to end against a real Kafka 4.0 broker (KRaft,
single node), in both directions:
faust worker + serve_asgi
HTTP POST -> await topic.send() on the worker's loop -> 200
agent in the same process consumed the message
real SIGINT to the process group -> exit 0, clean 1.4s shutdown
uvicorn + faust_lifespan (the literal issue #448 scenario)
HTTP POST -> await topic.send() -> 200, zero loop errors
agent consumed it, including the backlog from a previous run
That closes the item the pull request listed as unverified: the loop fix
works against a live broker, not just against the invariant asserted in the
test suite.
The runs also surfaced a genuine operational gotcha. On a first run against a
fresh cluster the topic does not exist when the app starts, so the agent
subscribes to nothing: messages produced by an endpoint are written to Kafka
but never processed until the process restarts. Nothing is lost -- the
backlog is consumed on the next start -- but it reads like a broken agent,
and co-hosted apps hit it more often than most because they usually produce
to and consume from the same topic.
Confirmed this is general Faust behaviour rather than anything to do with
co-hosting: `faust worker` reproduces it exactly, consuming nothing when the
topic is deleted beforehand and everything once it exists. Documented in the
caveats section rather than worked around, since creating topics up front is
the right fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011644QmUZejf8WRmmSXTJPp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Three related pieces of work on the web and observability layers:
Fixes #448
Fixes #435
Fixes #322
Reviewable commit by commit, in order.
1. The event-loop fix
This is the bug behind all three issues, and it is not really a FastAPI bug at all.
Faust apps are declared at module scope, but under uvicorn the app is started from a loop created later by
asyncio.run(). Several call sites resolvedapp.loopat declaration time, pinning the App to whichever loopmode'sget_event_loop()found at import — a loop that is never run. Everything built during startup then inherited that dead loop:faust workersurvived by accident:faust/cli/base.pyreused the same policy loopmodehad already grabbed, so the pinned loop happened to be the right one.asyncio.run()creates a different one, so the pin became fatal.The fix is to stop resolving the loop eagerly and let
mode.Servicelate-bind it inside_default_start()— i.e. in the loop that actually runs the app:Agent,Collection,Weband livecheckCaseno longer passloop=app.looptoService.__init__.App._new_transport()/_new_producer_transport()no longer passloop=, andTransport.loopresolves lazily (still settable; an explicitloop=still wins).Transport.create_conductor()no longer passesloop=.App.tablesno longer passesloop=to the table manager.Worth a reviewer's eye: the transport chain (
@app.agent→app.topics→_new_conductor()→app.transport) is the load-bearing part. Fixing only the fourService.__init__sites leavesapp._looppinned — found by measuring, not reading.A consequence caught by CI: nothing calls
set_event_loop()before the CLI runs any more, and Python 3.14 removed the get-or-create fallback fromget_event_loop_policy().get_event_loop().faust/cli/base.pynow routes throughmode.utils.loops.get_event_loop(), which keeps get-or-create across versions. Only the 3.14 legs caught this; 3.10–3.13 still auto-create.2.
faust.contrib.fastapiThe ASGI server drives:
or the worker drives, serving your ASGI app on its own loop:
Details that matter:
bind_to_running_loop()readsapp._loop, notapp.loop— the public property resolves and caches a loop as a side effect of being read, which is exactly what it is trying to detect. RaisesLoopMismatchnaming the usual causes.faust_app_running()usesmaybe_start(), so it composes with an already-running app and will not stop one it did not start.faust_lifespan()yieldsNone: Starlette merges a non-Nonelifespan value into the ASGI scope as state.serve_asgi()registers via the publicApp.service()decorator, so the server starts after table recovery — when it is actually safe to serve.fastapi/starlette, so it works with Starlette, Quart and Litestar too.uvicornis imported lazily.Also fixes
faust[aerospike], which shippedrequirements/extras/aerospike.txtwithout the matchingBUNDLESentry and so installed nothing despite being advertised in the README.tests/unit/test_packaging.pyguards both directions of that mapping.3. OpenTelemetry
opentelemetry-instrumentation-aiokafkaalready wrapsAIOKafkaProducer.sendandAIOKafkaConsumer.getmany, so most of a trace already works. The hop nobody outside Faust can supply isreceive→ process: the consumer runs in its own thread (ConsumerThread) andcontextvarsnever cross threads, so thereceivespan is opened and closed on a thread the agent never runs on. The result is an orphaned span and an unparented agent, which reads worse than no instrumentation.OpenTelemetrySensorcloses it — extracting trace context from the message headers and opening a{topic} processspan that stays current while the stream processes the event:FastAPI apps are instrumented automatically, but only when OpenTelemetry is installed and a real
TracerProvideris configured — until an SDK is configured the API is a no-op, so this never enables telemetry nobody asked for.opentelemetry-apionly, and never callsset_tracer_provider()— configuring the SDK is the application's job.traceparentheaders on the wire.TracingSensoris registered, since that one does inject.4. Per-endpoint web feature flags
Which built-in endpoints get served was decided entirely by
debug: it enabled the statistics and graph endpoints together or neither, and/routerand/tablewere mounted unconditionally with no way to turn them off — even though/tableserves table data over HTTP and/routerpublishes the URL of every other worker.default_alias="debug"resolves toconf.debugat read time when unset, so an app setting none of these serves exactly what it served before./routerand/tablekeep defaulting to on because disabling/routerbreaks@table_routeacross nodes — they are simply switchable now.Selection moved into
Web._enabled_blueprints(), driven by ablueprint_flagsmapping. Blueprints with no entry are always enabled, so anything a subclass or user adds is unaffected.5.
/performance/metrics endpointOff by default, no new dependency, independent of
debug. Not a flatMonitor.asdict()dump — two things are computed that monitor does not provide:statistics.quantiles, which raises on fewer than two samples — a metrics endpoint should not fail on a worker that just started.New extras
requirements/test.txtgains onlyopentelemetry-api,-sdkanduvicorn— all pure Python — so the new code is covered in CI without pullingfastapi/pydantic-coreinto all 15 matrix legs. FastAPI-dependent tests useimportorskip; the OpenTelemetry instrumentation paths are covered by stubbing the module throughsys.modules.Backwards compatibility
Audited by snapshotting seven production-shaped app configs (plain, debug,
web_enabled=False,web_in_thread, custom port/host, prod-like, debug+in_thread) onmasterand on this branch, then diffing settings, route tables, reverse route names, agents, tables, topics and loop identity.The only difference is loop identity —
Falseon master,Truehere — which is the bug being fixed. Settings, routes and registered objects are identical for every config.install_requiresis unchanged,faust/web/base.py's__all__is unchanged, and the set of shippedfaust.*packages is byte-identical to master.tests/unit/web/test_blueprint_flags.py::Test_backwards_compatibilitypins the served-blueprint set for the default anddebug=Truecases so this cannot silently regress.That audit also caught one genuine production-visible change and fixed it:
setup.pyexcluded"examples"but not"examples.*", sofind_packageswas installing the example sub-packages into site-packages under anexamplesnamespace. Renamingexamples/fastapitoexamples/fastapi_projecttherefore changed what the wheel installs. Excluding"examples.*"means no example package ships either way.Testing
2320 passed, 8 skipped locally.
isort,black,flake8andbanditclean; Sphinx builds.New tests bind no socket and start no uvicorn, so they stay fast and keep the autouse lingering-thread/task guards happy:
tests/functional/test_fastapi_cohost.pyreproduces the import-time-vs-running-loop split without a broker, by declaring the app in a synchronous fixture and asserting fromasynctests. Verified these fail when the old code is restored.tests/unit/contrib/test_opentelemetry.pyasserts against a realInMemorySpanExporterthat the process span continues the trace from atraceparentheader and that nested spans parent to it.tests/unit/web/test_blueprint_flags.pycovers the flag matrix and the back-compat cases.tests/functional/web/test_metrics.pydrives/performance/through the existing aiohttp test client.Verified against a real broker
Both directions were run end to end against Kafka 4.0 (KRaft, single node):
Signal handling
Checked against real SIGINTs — a
mode.WorkerhostingAsgiServicewith uvicorn serving on a real port, signalled at the process group like a terminal Ctrl-C, single and repeated, with and without the override. All four combinations exit 0 and complete a slow service's drain.That measurement corrected the code comment:
mode.Workerregisters vialoop.add_signal_handler(), which is delivered through asyncio's wakeup fd, so uvicorn'ssignal.signal()does not displace it. The override is kept to avoid a second concurrent shutdown path, and the docstring now says that rather than something untrue.Unrelated issues surfaced, not fixed here
extra/tools/render_configuration_reference.pyiteratesrelated_cli_optionsstring values character by character, somake configrefrewrites:option:faust --debug`` as:option:faust -`, :option:`faust d`, ...` and drops hand-written prose. The new settings are hand-added to `settingref.txt` in the generator's format instead.make configcheckfails on master becauseweb_application_optionswas never added tosettingref.txt. Documented here so the symmetric difference is empty, butconfigcheckis a tox environment CI never invokes, which is why it drifted.FlakyFailurein the partition-assignor property test (5123ms against a 4000ms deadline on one run, 3455ms on the next). Pre-existing and unrelated; the deadline is raised on PyPy only, so CPython keeps the tighter bound.faust workerreproduces it identically), so it is documented in the userguide caveats rather than worked around.