Fold the wrapper-reorder release notes into the lifecycle docs

Rolled down from the stack's docs-only tip PR: the lifecycle section now
states that startup completes before plugin asgi_wrapper middleware sees
any request, and the RELEASE_NOTES_DRAFT_05.md scratch file is gone -
its content lands in the changelog in the tasks-endpoint PR at the top
of the stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
This commit is contained in:
Alex Garcia 2026-09-01 17:53:21 -07:00
commit 89ad91da27
2 changed files with 1 additions and 25 deletions

View file

@ -1,24 +0,0 @@
# Release notes draft — ticket 05 (wrapper reorder)
Scratch file: content to be folded into `docs/changelog.rst` by the final
docs PR in this stack, which also deletes this file. Not part of the shipped
docs on its own.
## Plugin hooks
- Plugin `asgi_wrapper` middleware now always runs **after** Datasette
startup has completed. Wrappers can rely on startup hooks — including
internal-database migrations run by other plugins' `startup()` hooks —
having already executed before their code sees an `http` or `websocket`
ASGI scope. This applies on every deployment path: behind a real ASGI
lifespan-aware server, and on the first-request fallback used by bare
`app()` embedding and test clients that never send lifespan events.
- Short-circuiting wrappers — ones that return a response without calling
the wrapped application, such as an auth plugin returning a 401/403 or a
CORS plugin answering a preflight request — no longer defer startup
indefinitely. Startup now runs unconditionally before any wrapper sees
the scope, so it can no longer be skipped by requests that never reach
the inner app.
- `lifespan` scopes are unaffected by this change and continue to flow
through plugin `asgi_wrapper` middleware exactly as before, so plugins
that inspect or wrap lifespan events keep working unmodified.

View file

@ -1426,7 +1426,7 @@ Three trigger paths
~~~~~~~~~~~~~~~~~~~
- **``datasette serve`` (CLI)** — startup and ``uvicorn.Server.serve()`` both run inside a single ``asyncio.run()`` call, so there is exactly one event loop for the whole life of the process.
- **ASGI lifespan**``Datasette.app()`` wires startup and background-task launch into the ``on_startup`` list, and shutdown into the ``on_shutdown`` list, of an internal ``AsgiLifespan`` wrapper. A spec-compliant ASGI server (uvicorn, hypercorn, and others) sends the ``lifespan.startup`` message and waits for ``lifespan.startup.complete`` before delivering any ``http`` or ``websocket`` scope, so startup — including every plugin's own internal-database migrations — is guaranteed to have finished before any request reaches Datasette. If a ``startup`` hook raises, ``AsgiLifespan`` sends ``lifespan.startup.failed`` with the exception message instead of hanging or crashing ambiguously, so the host can abort the boot cleanly.
- **ASGI lifespan**``Datasette.app()`` wires startup and background-task launch into the ``on_startup`` list, and shutdown into the ``on_shutdown`` list, of an internal ``AsgiLifespan`` wrapper. A spec-compliant ASGI server (uvicorn, hypercorn, and others) sends the ``lifespan.startup`` message and waits for ``lifespan.startup.complete`` before delivering any ``http`` or ``websocket`` scope, so startup — including every plugin's own internal-database migrations — is guaranteed to have finished before any request reaches Datasette, including requests seen by plugin :ref:`asgi_wrapper <plugin_asgi_wrapper>` middleware. If a ``startup`` hook raises, ``AsgiLifespan`` sends ``lifespan.startup.failed`` with the exception message instead of hanging or crashing ambiguously, so the host can abort the boot cleanly.
- **First-request fallback** — an internal ``AsgiRunOnFirstRequest`` wrapper runs the same startup work as a safety net for hosts that never send ASGI lifespan events at all: some ASGI mounts, a bare ``app()`` embedded inside another framework, and :ref:`datasette.client <internals_datasette_client>` / test clients, which drive requests directly over ``httpx.ASGITransport`` without ever emitting ``lifespan.startup``. It runs startup exactly once, the first time any non-lifespan scope arrives, guarded by a lock so that concurrent early requests can't run it twice.
All three paths call the same idempotent internal methods, so it is safe for more than one of them to fire — lifespan startup completing and then a first request arriving afterwards is a no-op the second time. A host that never sends lifespan events and never goes through the CLI degrades to first-request timing: startup runs on the first request instead of before it, exactly as Datasette always worked prior to this lifecycle guarantee. This is a deliberate fallback rather than a regression — see :ref:`datasette_add_background_task` for how to opt out of launching background tasks (the ``--get`` CLI path) or drive startup and launch explicitly (tests, headless embedders).