diff --git a/research/lifecycle-prototype/demo.html b/research/lifecycle-prototype/demo.html new file mode 100644 index 0000000..4d7284c --- /dev/null +++ b/research/lifecycle-prototype/demo.html @@ -0,0 +1,1172 @@ + + +Lifecycle ownership experiment + +

Who owns observability cleanup?

+

Throwaway prototype: can one explicit owner replace lifecycle coordination without losing shutdown telemetry? Compare framework-lifespan ownership with a context around the runner.

+

Recorded walkthroughs replay actual Python runs. Free play is an illustrative state model, not a running framework. No production code has changed.

+

Recorded walkthroughs

+ + + +

+

Observed sequence

Complete-run observations

+
+

Free play

Try closing telemetry while application cleanup is still pending. Borrowed providers remain under their external owner's control.

+ +

+
+ + diff --git a/research/lifecycle-prototype/floor-results.json b/research/lifecycle-prototype/floor-results.json new file mode 100644 index 0000000..dfa0da1 --- /dev/null +++ b/research/lifecycle-prototype/floor-results.json @@ -0,0 +1,79 @@ +{ + "faststream": "0.6.0", + "results": [ + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "happy", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "combined-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [ + "Observability cleanup: cleanup failed" + ], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + } + ] +} diff --git a/research/lifecycle-prototype/naive-wrapper-result.json b/research/lifecycle-prototype/naive-wrapper-result.json new file mode 100644 index 0000000..a64899c --- /dev/null +++ b/research/lifecycle-prototype/naive-wrapper-result.json @@ -0,0 +1,25 @@ +{ + "framework": "faststream", + "variant": "lifespan", + "scenario": "happy", + "status": "fail", + "events": [ + "own provider", + "exporter shutdown" + ], + "exported_spans": [], + "shutdown_calls": 1, + "error": "TypeError", + "error_message": "make_application..faststream_lifespan() got an unexpected keyword argument 'context__'", + "notes": [], + "checks": { + "expected_error": false, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": false, + "arguments": false, + "lifespan_result": true, + "external_survives": true + } +} diff --git a/research/lifecycle-prototype/report.md b/research/lifecycle-prototype/report.md new file mode 100644 index 0000000..9d71374 --- /dev/null +++ b/research/lifecycle-prototype/report.md @@ -0,0 +1,140 @@ +# Lifecycle ownership spike + +Throwaway code, not a production implementation. Based on main `2af92e041eb0d22fffe1ef3e3961cfad031342c5`; +compares alternatives to PR [#443](https://github.com/modern-python/lite-bootstrap/pull/443), head `840f6ce`. + +## Decision + +Use the framework lifespan as the normal owner of observability. Let code that drives +`start()` / `stop()` manually own cleanup explicitly. Share the resource ownership and +error-preservation policy internally. Keep the existing bootstrapper interface for 2.0. + +This is a deliberate change to manual-operation behavior. Update #434's contract before +changing #443. The prototype does not authorize that compatibility change or implement it. + +An explicit context around every runner is feasible, but replacing the normal framework +lifespan with it would increase deployment responsibilities for callers. A broad new +public observability interface is not justified by this experiment. + +## Run and inspect + +From the repository root: + +```bash +uv run --no-sync python research/lifecycle-prototype/run.py +``` + +Every case gets a fresh process because OTel's global tracer provider is set once. +The command prints each outcome and saves full observations in [results.json](results.json). +No production modules are changed, no telemetry goes to a network exporter, and broker +network start/stop are replaced with local stand-ins. + +Individual working examples: + +```bash +uv run --no-sync python research/lifecycle-prototype/run.py fastapi lifespan happy +uv run --no-sync python research/lifecycle-prototype/run.py faststream lifespan happy +uv run --no-sync python research/lifecycle-prototype/run.py faststream explicit manual +uv run --no-sync python research/lifecycle-prototype/run.py fastapi explicit combined-error +``` + +[demo.html](demo.html) is a self-contained recorded walkthrough and free-play model. +Its recorded data comes from the Python runs; its free-play state machine is illustrative. +Browser preview was blocked by the browser's local-file URL policy, so the HTML has not +received visual verification. The Python experiments ran successfully. + +## What was compared + +Both designs use the same proposed `observability()` owner in [run.py](run.py). +It creates a provider only when none exists, shuts down only that provider, and preserves +the original error if cleanup also fails. This isolates lifecycle placement from SDK policy. + +| | A: Framework lifespan | B: Explicit runner context | +|---|---|---| +| Actual placement | `managed_lifespan(user_lifespan, state)` passed to the framework constructor | `with observability(state)` around the runner | +| Shutdown order | Framework shutdown, user shutdown, telemetry cleanup | Framework shutdown, user shutdown, telemetry cleanup | +| Ordinary ASGI caller | Framework enters and exits the owner | Caller must enclose the server's complete run | +| Manual FastStream start/stop | Requires explicit ownership | Works inside the context | +| Installed prototype hooks / flags | Constructor lifespan helper; no instance replacement or active flag | No lifecycle hooks or active flag | +| Existing bootstrapper compatibility | Can retain the existing interface with one lifespan replacement | Requires caller entry-point changes | + +The prototype's constructor helper is an illustrative seam, not a proposed public function +with a diagnostic `state` parameter. Retaining lite-bootstrap's existing application-taking +interface would still replace its lifespan callable. That implementation cost remains. + +## Evidence + +34 isolated runs: **33 passed, one unsupported by the chosen contract**. The unsupported +case calls FastStream start/stop directly with design A, so neither telemetry initialization +nor automatic cleanup is entered. This is a visible limitation, not a passing cleanup case. + +| Observation | A | B | +|---|---|---| +| FastAPI request span, user startup/shutdown spans, lifespan state | Preserved | Preserved | +| FastStream work, shutdown-hook, broker-drain and user-shutdown spans | Preserved | Preserved | +| FastStream configured lifespan argument | Preserved | Preserved | +| Telemetry setup failure | Owned provider closed once | Owned provider closed once | +| User startup/shutdown failure | Original exception preserved; provider closed once | Same | +| Broker/shutdown-hook failure | User cleanup runs before telemetry closes | Same | +| Exporter shutdown failure | Reported | Reported | +| Lifecycle failure plus exporter shutdown failure | Original exception plus cleanup note | Same | +| Existing external provider | Same provider, zero owner shutdowns, still exports afterward | Same | + +Installed versions: FastAPI 0.162.0, FastStream 0.7.8, Starlette 1.10.0, +OTel SDK 1.45.2 and FastAPI instrumentation 0.66b2. Additionally ran the FastStream 0.6.0 +lifespan happy path and explicit combined-error path successfully, using the isolated +package in `/tmp/lite-faststream-floor-434`. These are spot checks, not full floor validation. + +The naive constructor wrapper failed FastStream's injection with +`unexpected keyword argument 'context__'`. Preserving the original callable with +`functools.wraps` fixed it. [naive-wrapper-result.json](naive-wrapper-result.json) preserves +that observation. Reproduce it by removing only the wrapper's `functools.wraps` decorator. + +## Caller setup and deployment cost + +Design A adds lifespan composition at construction in the prototype. Framework-controlled +startup then determines the correct lifetime. Constructor composition must preserve the +user callable's signature; wrapping FastStream's already-injected `lifespan_context`, as +#443 does, avoids that particular injection problem. + +Design B adds a context around the complete runner. It fits embedded servers, scripts, +and manual broker operation. An application exported for a server to import cannot hold +a synchronous module-level `with` block open through serving. Such deployments still need +a per-worker initialization/cleanup hook, naturally the ASGI lifespan. + +The installed Uvicorn runner also requires an import string for reload or multiple workers +(`uvicorn/main.py`, reload/worker validation and supervisor branches). A context around the +parent launcher is insufficient evidence of worker ownership. Worker/reload behavior was +read in source, not executed in this spike; no support claim is made for it. + +## Exact simplification available in #443 + +If 2.0 adopts explicit cleanup for manual operation: + +1. In `FastStreamBootstrapper._wrap_lifecycle`, delete the captured `application_stop`, + `lifespan_active`, its `nonlocal` declaration and reset, the nested `stop()` wrapper, + and `application.stop = stop` including its type-checker suppression. +2. Keep one lifespan wrapper. Keep exception-safe cleanup and the one-bootstrapper guard. + A smaller interface does not remove error-preservation or SDK ownership requirements. +3. Replace the six new automatic-manual-cleanup regression cases with coverage and examples + for the agreed explicit manual-cleanup contract. Preserve shutdown-span and failure-path + regressions for the ASGI lifespan. +4. Keep FastAPI's existing lifespan attachment. Share cleanup-error policy privately where + the two real implementations need it; do not introduce a universal framework adapter. +5. Update the migration guide to state that direct start/stop and application context-manager + usage require an explicit telemetry owner or `bootstrapper.teardown()` in `finally`. + +If automatic cleanup for manual operation remains a requirement, keep #443's stop fallback. +It implements a real compatibility promise; renaming or moving it would not remove that cost. + +## Limits and next implementation + +The spike validates tracing lifetime and one provider's ownership. It does not implement +logging, Sentry, metrics, profiling, provider conflicts, partial custom-instrumentor rollback, +external instrumentor ownership, worker startup, cancellation, or repeated lifetimes. +Process-global SDK state remains set once and a closed provider cannot be made reusable by +this interface. Issue #436's ownership/rollback work remains necessary. + +The recommended next implementation is a narrow revision of #434's manual-operation contract +and #443's lifecycle glue. Defer configuration and public-interface redesign. No production +changes, PR updates, merge, release, or issue-contract edits are part of this spike. diff --git a/research/lifecycle-prototype/results.json b/research/lifecycle-prototype/results.json new file mode 100644 index 0000000..7b252c6 --- /dev/null +++ b/research/lifecycle-prototype/results.json @@ -0,0 +1,1127 @@ +{ + "versions": { + "fastapi": "0.162.0", + "faststream": "0.7.8", + "opentelemetry-sdk": "1.45.2", + "opentelemetry-instrumentation-fastapi": "0.66b2", + "starlette": "1.10.0" + }, + "results": [ + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "happy", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "setup-error", + "status": "pass", + "events": [ + "own provider", + "exporter shutdown" + ], + "exported_spans": [], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "startup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "shutdown-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "cleanup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "RuntimeError", + "error_message": "cleanup failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "combined-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [ + "Observability cleanup: cleanup failed" + ], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "lifespan", + "scenario": "external", + "status": "pass", + "events": [ + "borrow provider", + "user startup", + "work", + "user shutdown", + "external after cleanup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown", + "external after cleanup" + ], + "shutdown_calls": 0, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "happy", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "setup-error", + "status": "pass", + "events": [ + "own provider", + "exporter shutdown" + ], + "exported_spans": [], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "startup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "shutdown-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "cleanup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "RuntimeError", + "error_message": "cleanup failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "combined-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [ + "Observability cleanup: cleanup failed" + ], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "fastapi", + "variant": "explicit", + "scenario": "external", + "status": "pass", + "events": [ + "borrow provider", + "user startup", + "work", + "user shutdown", + "external after cleanup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "GET /work", + "user shutdown", + "external after cleanup" + ], + "shutdown_calls": 0, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "happy", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "setup-error", + "status": "pass", + "events": [ + "own provider", + "exporter shutdown" + ], + "exported_spans": [], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "startup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "shutdown-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "cleanup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "RuntimeError", + "error_message": "cleanup failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "combined-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [ + "Observability cleanup: cleanup failed" + ], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "external", + "status": "pass", + "events": [ + "borrow provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "external after cleanup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "external after cleanup" + ], + "shutdown_calls": 0, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "hook-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "broker-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "lifespan", + "scenario": "manual", + "status": "unsupported", + "events": [ + "work", + "shutdown hook", + "broker drain" + ], + "exported_spans": [], + "shutdown_calls": 0, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "happy", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "setup-error", + "status": "pass", + "events": [ + "own provider", + "exporter shutdown" + ], + "exported_spans": [], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "startup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "shutdown-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "cleanup-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "RuntimeError", + "error_message": "cleanup failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "combined-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [ + "Observability cleanup: cleanup failed" + ], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "external", + "status": "pass", + "events": [ + "borrow provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "external after cleanup", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "external after cleanup" + ], + "shutdown_calls": 0, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "hook-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "broker-error", + "status": "pass", + "events": [ + "own provider", + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown", + "exporter shutdown" + ], + "exported_spans": [ + "user startup", + "work", + "shutdown hook", + "broker drain", + "user shutdown" + ], + "shutdown_calls": 1, + "error": "ValueError", + "error_message": "primary failed", + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + }, + { + "framework": "faststream", + "variant": "explicit", + "scenario": "manual", + "status": "pass", + "events": [ + "own provider", + "work", + "shutdown hook", + "broker drain", + "exporter shutdown" + ], + "exported_spans": [ + "work", + "shutdown hook", + "broker drain" + ], + "shutdown_calls": 1, + "error": null, + "error_message": null, + "notes": [], + "checks": { + "expected_error": true, + "primary_preserved": true, + "cleanup_note": true, + "shutdown_ownership": true, + "shutdown_spans": true, + "arguments": true, + "lifespan_result": true, + "external_survives": true + } + } + ] +} diff --git a/research/lifecycle-prototype/run.py b/research/lifecycle-prototype/run.py new file mode 100644 index 0000000..e330b74 --- /dev/null +++ b/research/lifecycle-prototype/run.py @@ -0,0 +1,276 @@ +"""THROWAWAY lifecycle contract experiment. Run with: uv run --no-sync python research/lifecycle-prototype/run.py.""" + +import asyncio +import contextlib +import functools +import importlib.metadata +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from fastapi import FastAPI, Request +from faststream.asgi import AsgiFastStream +from faststream.redis import RedisBroker +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import ProxyTracerProvider, get_tracer, get_tracer_provider, set_tracer_provider +from starlette.testclient import TestClient + + +class ProbeExporter(InMemorySpanExporter): + def __init__(self, state, fail=False): + super().__init__() + self.state = state + self.fail = fail + self.shutdown_calls = 0 + + def shutdown(self): + self.shutdown_calls += 1 + self.state["events"].append("exporter shutdown") + super().shutdown() + if self.fail: + raise RuntimeError("cleanup failed") + + +def span(state, name): + state["events"].append(name) + with get_tracer("prototype").start_as_current_span(name): + pass + + +@contextlib.contextmanager +def observability(state): + """Proposed process-level ownership, shared by both designs; no production imports.""" + owned_provider = None + primary_error = None + try: + provider = get_tracer_provider() + if isinstance(provider, ProxyTracerProvider): + provider = TracerProvider(shutdown_on_exit=False) + owned_provider = provider + provider.add_span_processor(SimpleSpanProcessor(state["exporter"])) + set_tracer_provider(provider) + state["events"].append("own provider") + else: + state["events"].append("borrow provider") + if state["scenario"] == "setup-error": + raise state["primary"] + yield provider + except BaseException as error: + primary_error = error + raise + finally: + if owned_provider is not None: + try: + owned_provider.shutdown() + except Exception as error: + if primary_error is None: + raise + primary_error.add_note(f"Observability cleanup: {error}") + + +def managed_lifespan(user_lifespan, state): + """Design A: constructor-supplied lifespan composition, with no application mutation.""" + + @contextlib.asynccontextmanager + @functools.wraps(user_lifespan) + async def lifespan(*args, **kwargs): + with observability(state): + async with user_lifespan(*args, **kwargs) as result: + yield result + + return lifespan + + +def make_application(framework, variant, state): + scenario = state["scenario"] + + @contextlib.asynccontextmanager + async def fastapi_lifespan(app): + state["arguments_preserved"] = app is state["app"] + span(state, "user startup") + if scenario == "startup-error": + raise state["primary"] + try: + yield {"service": "ready"} + finally: + span(state, "user shutdown") + if scenario in ("shutdown-error", "combined-error"): + raise state["primary"] + + @contextlib.asynccontextmanager + async def faststream_lifespan(service: str = "default"): + state["arguments_preserved"] = service == "configured" + span(state, "user startup") + if scenario == "startup-error": + raise state["primary"] + try: + yield + finally: + span(state, "user shutdown") + if scenario in ("shutdown-error", "combined-error"): + raise state["primary"] + + user_lifespan = fastapi_lifespan if framework == "fastapi" else faststream_lifespan + lifespan = managed_lifespan(user_lifespan, state) if variant == "lifespan" else user_lifespan + if framework == "fastapi": + app = FastAPI(lifespan=lifespan) + + @app.get("/work") + async def work(request: Request): + span(state, "work") + return {"service": request.state.service} + + FastAPIInstrumentor.instrument_app(app, exclude_spans=["receive", "send"]) + else: + + async def shutdown_hook(): + span(state, "shutdown hook") + if scenario == "hook-error": + raise state["primary"] + + app = AsgiFastStream(RedisBroker(), lifespan=lifespan, on_shutdown=[shutdown_hook], logger=None) + state["app"] = app + return app + + +async def drive_faststream(app, state): + async def stop_broker(): + span(state, "broker drain") + if state["scenario"] == "broker-error": + raise state["primary"] + + with ( + patch.object(app.broker, "start", new=AsyncMock()), + patch.object(app.broker, "stop", new=AsyncMock(side_effect=stop_broker)), + ): + if state["scenario"] == "manual": + await app.start() + span(state, "work") + await app.stop() + else: + async with app.start_lifespan_context(run_extra_options={"service": "configured"}): + span(state, "work") + + +def child(framework, variant, scenario): + state = {"scenario": scenario, "events": [], "primary": ValueError("primary failed")} + exporter = ProbeExporter(state, fail=scenario in ("cleanup-error", "combined-error")) + state["exporter"] = exporter + external = scenario == "external" + if external: + external_provider = TracerProvider(shutdown_on_exit=False) + external_provider.add_span_processor(SimpleSpanProcessor(exporter)) + set_tracer_provider(external_provider) + app = make_application(framework, variant, state) + error = None + try: + with observability(state) if variant == "explicit" else contextlib.nullcontext(): + if framework == "fastapi": + with TestClient(app) as client: + response = client.get("/work") + state["result_preserved"] = response.json() == {"service": "ready"} + else: + asyncio.run(drive_faststream(app, state)) + except Exception as caught: + error = caught + if external: + span(state, "external after cleanup") + names = [item.name for item in exporter.get_finished_spans()] + expected_error = scenario.endswith("error") + owned_shutdowns = 0 if external or (scenario == "manual" and variant == "lifespan") else 1 + expected_names = { + "setup-error": [], + "startup-error": ["user startup"], + "hook-error": ["user startup", "work", "shutdown hook", "user shutdown"], + "broker-error": ["user startup", "work", "shutdown hook", "broker drain", "user shutdown"], + "manual": ["work", "shutdown hook", "broker drain"] if variant == "explicit" else [], + }.get( + scenario, + ["user startup", "work", "user shutdown"] + if framework == "fastapi" + else ["user startup", "work", "shutdown hook", "broker drain", "user shutdown"], + ) + primary_expected = scenario in ( + "setup-error", + "startup-error", + "shutdown-error", + "hook-error", + "broker-error", + "combined-error", + ) + checks = { + "expected_error": (error is not None) == expected_error, + "primary_preserved": not primary_expected or error is state["primary"], + "cleanup_note": scenario != "combined-error" + or any("cleanup failed" in note for note in getattr(error, "__notes__", [])), + "shutdown_ownership": exporter.shutdown_calls == owned_shutdowns, + "shutdown_spans": all(name in names for name in expected_names), + "arguments": scenario in ("setup-error", "manual") or state.get("arguments_preserved", False), + "lifespan_result": framework != "fastapi" or expected_error or state.get("result_preserved", False), + "external_survives": not external + or (get_tracer_provider() is external_provider and "external after cleanup" in names), + } + result = { + "framework": framework, + "variant": variant, + "scenario": scenario, + "status": "unsupported" + if scenario == "manual" and variant == "lifespan" + else "pass" + if all(checks.values()) + else "fail", + "events": state["events"], + "exported_spans": names, + "shutdown_calls": exporter.shutdown_calls, + "error": type(error).__name__ if error else None, + "error_message": str(error) if error else None, + "notes": getattr(error, "__notes__", []), + "checks": checks, + } + if external: + external_provider.shutdown() + print(json.dumps(result)) + + +def main(): + if len(sys.argv) == 4: + child(*sys.argv[1:]) + return + cases = ["happy", "setup-error", "startup-error", "shutdown-error", "cleanup-error", "combined-error", "external"] + results = [] + for framework in ("fastapi", "faststream"): + for variant in ("lifespan", "explicit"): + for scenario in cases + (["hook-error", "broker-error", "manual"] if framework == "faststream" else []): + process = subprocess.run( + [sys.executable, __file__, framework, variant, scenario], text=True, capture_output=True, check=True + ) + result = json.loads(process.stdout) + results.append(result) + print(f"{framework:10} {variant:9} {scenario:15} {result['status']}") + output = { + "versions": { + name: importlib.metadata.version(name) + for name in ( + "fastapi", + "faststream", + "opentelemetry-sdk", + "opentelemetry-instrumentation-fastapi", + "starlette", + ) + }, + "results": results, + } + path = Path(__file__).with_name("results.json") + path.write_text(json.dumps(output, indent=2) + "\n") + print(f"Recorded {len(results)} isolated runs in {path}") + if any(row["status"] == "fail" for row in results): + raise SystemExit(1) + + +if __name__ == "__main__": + main()