From 89ad91da2700244edd15b44eda5a0b7692c36126 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Tue, 1 Sep 2026 17:53:21 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA --- RELEASE_NOTES_DRAFT_05.md | 24 ------------------------ docs/internals.rst | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 RELEASE_NOTES_DRAFT_05.md diff --git a/RELEASE_NOTES_DRAFT_05.md b/RELEASE_NOTES_DRAFT_05.md deleted file mode 100644 index 9e0de848..00000000 --- a/RELEASE_NOTES_DRAFT_05.md +++ /dev/null @@ -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. diff --git a/docs/internals.rst b/docs/internals.rst index b3c217bf..ddbaf18f 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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 ` 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 ` / 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).