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/changelog.rst b/docs/changelog.rst index 66a7caab..18bf96dc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,46 @@ Changelog ========= +.. _v1_0_a39: + +1.0a39 (unreleased) +------------------- + +This alpha gives plugins a real process lifecycle. Previously, ``datasette serve`` ran ``invoke_startup()`` on a temporary event loop that was closed before the server's own loop was created, so anything a ``startup`` hook scheduled with ``asyncio.create_task()`` - or any loop-bound primitive it created - could silently die before it ever ran. Every non-CLI deployment was worse off still: startup, including populating the internal database's table catalog, didn't run at all until the *first* HTTP request arrived, so plugins compensated with ``asgi_wrapper`` bootstrap shims, ``tryfirst=True`` ordering hacks and hand-rolled "has this started yet" flags. This release fixes all three problems together: one event loop for the whole process, startup wired into ASGI lifespan, and a new supervised background-task API plus a ``shutdown`` hook, so plugins no longer need to build any of that scaffolding themselves. See :ref:`datasette_lifecycle` for the full guarantee. + +- ``datasette serve`` now runs startup and the server on a single ``asyncio`` event loop, instead of a temporary loop that was discarded before ``uvicorn.run()`` created the loop that actually serves requests. A ``startup`` hook can now safely call ``asyncio.create_task()``, or create loop-bound primitives such as ``asyncio.Lock``, ``asyncio.Queue`` or ``asyncio.Event``, and expect them to still be alive once the server starts handling requests. +- Startup - the internal database's table catalog, canned queries and column type configuration, and every :ref:`plugin_hook_startup` hook - is now wired into the ASGI ``lifespan.startup`` event via ``Datasette.app()``. A spec-compliant ASGI server (uvicorn, hypercorn, and others) completes ``lifespan.startup`` before delivering any request, so startup is now guaranteed to have finished before the first request in every deployment, not only ``datasette serve``; previously the table catalog in particular only populated on the first request, even when running under ``datasette serve``. A failing ``startup`` hook now surfaces as ``lifespan.startup.failed`` with the exception message, instead of leaving the ASGI host to hang or crash ambiguously. +- The pre-existing first-request fallback is preserved as a safety net for hosts that never send ASGI lifespan events at all - some ASGI mounts, bare ``app()`` embedding, ``datasette.client``/test clients - and is idempotent alongside the lifespan path, so it's safe for both to fire. +- New :ref:`datasette_add_background_task` API: plugins register supervised, long-lived background work - typically from a ``startup`` hook - and core owns launching it, once every ``startup`` hook has run. Core keeps a strong reference for the life of the process (no more silently garbage-collected fire-and-forget tasks), logs crashes with a full traceback to the ``datasette.background_tasks`` logger instead of a silent "Task exception was never retrieved", and cancels every task with a five-second grace period on shutdown. There is no automatic restart of a crashed task in this release. Registration returns a :ref:`BackgroundTask ` handle (``.name``, ``.state``, ``.task``, ``.exception``, ``.cancel()``). New :ref:`await datasette.start_background_tasks() ` method lets tests and headless embedders launch registered tasks explicitly, without running a server. +- New ``/-/tasks`` JSON debug endpoint lists every supervised background task and its state, in the style of ``/-/threads``. See :ref:`JsonDataView_tasks`. It requires the ``permissions-debug`` permission, since a crashed task's recorded exception can reveal internal details such as file paths. +- New :ref:`plugin_hook_shutdown` plugin hook, called during graceful shutdown (Ctrl-C, ``SIGTERM``) before background tasks are cancelled and before database connections are closed, so a plugin can tell its own background work to stop gracefully while a database connection is still available to write out final state. Exceptions raised by a ``shutdown`` hook are logged, not raised, so one plugin's broken teardown code cannot block another plugin's cleanup or Datasette's own database close. It is not called on a hard kill (``SIGKILL``). +- Plugin ``asgi_wrapper`` middleware now always runs *after* startup has completed, on every deployment path including the first-request fallback - a wrapper that short-circuits and never calls the wrapped app (an auth check returning a 401, a CORS preflight response) can no longer defer startup indefinitely. ``lifespan`` scopes are unaffected by this change and continue to flow through plugin wrappers exactly as before. +- The ``uvicorn`` dependency floor is now ``uvicorn>=0.29``, up from ``uvicorn>=0.11``. +- ``datasette serve --headers`` and ``--token`` are only valid alongside ``--get``; that usage error is now raised immediately after the ``Datasette`` instance is constructed and before startup runs, instead of after ``invoke_startup()`` - and therefore every plugin's ``startup`` hook - had already executed. + +Migrating away from ``asgi_wrapper`` bootstrap hacks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your plugin uses ``asgi_wrapper`` purely to detect "is this the first request" so that it can lazily start some background work, you can delete that code: + +.. list-table:: + :header-rows: 1 + + * - Before + - After + * - An ``asgi_wrapper`` that checks a module-level flag and calls ``asyncio.create_task()`` (or awaits an ``async def`` closure) the first time it sees a request scope + - Call ``datasette.add_background_task()`` from a :ref:`plugin_hook_startup` hook + * - A hand-rolled ``_ensure_started`` / ``_started`` flag guarded by a lock, to avoid starting the work twice + - Not needed - registration and launch are both idempotent and safe to call from multiple places + * - An ``asgi_wrapper`` that sniffs the ``lifespan.shutdown`` message in its receive callable to run cleanup + - Implement the :ref:`plugin_hook_shutdown` hook instead + * - A fire-and-forget ``asyncio.create_task()`` with no reference kept, plus a README caveat like "no traffic, no runs" or "ping the server to keep the scheduler alive" + - ``datasette.add_background_task()`` - core keeps a strong reference and launches the task once, as soon as startup finishes, whether or not any request ever arrives + * - ``tryfirst=True`` on a ``startup`` hook, to make sure it runs before another plugin's task-starting code + - Not needed - ``add_background_task()`` launch happens only after *every* ``startup`` hook across every plugin has completed, so registration order between plugins doesn't matter + +`datasette-cron `__ and `datasette-enrichments `__ are being migrated to this pattern as worked examples of the mapping above. + .. _v1_0_a38: 1.0a38 (2026-08-06) diff --git a/docs/internals.rst b/docs/internals.rst index d2bd46ef..3b1eb67b 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1403,7 +1403,141 @@ Release all resources held by this ``Datasette`` instance. This calls :ref:`data If a call to ``Database.close()`` on one of the attached databases raises an exception, ``Datasette.close()`` will continue trying to close the remaining databases and will re-raise the first exception after every database has been processed. -When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``. +When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``. See :ref:`datasette_lifecycle` for where ``close()`` fits into the full startup-to-shutdown sequence. + +.. _datasette_lifecycle: + +Application lifecycle +--------------------- + +Datasette guarantees a fixed sequence of events between the moment a ``Datasette`` instance is constructed and the moment its resources are released: + +1. ``Datasette(...)`` — the constructor runs synchronously and does not run plugin hooks. +2. **Startup** — ``await datasette.invoke_startup()`` runs once: it populates the internal database's catalog of table schemas (:ref:`internals_internal`), loads canned queries and column type configuration, then calls every registered :ref:`plugin_hook_startup` hook, in plugin registration order. When Datasette is being served, table-count precomputation for immutable databases runs immediately before this, as part of the same startup sequence. +3. **Background-task launch** — once *every* ``startup`` hook has finished (not before), every task registered with :ref:`datasette_add_background_task` — by any plugin — is launched. A task registered by one plugin's ``startup`` hook can safely depend on state set up by another plugin's ``startup`` hook, because launch only happens after the whole round of hooks completes. +4. **Serving** — the instance handles requests (or, for headless or CLI use, does whatever the embedding program does with it). +5. **Shutdown** — triggered by the ASGI ``lifespan.shutdown`` event (Ctrl-C, ``SIGTERM``) or the end of a ``datasette serve`` process: every :ref:`plugin_hook_shutdown` hook runs first, while background tasks are still alive, so a plugin can tell its own task to wind down gracefully; every still-running background task is then cancelled and given a five-second grace period to actually stop; finally every database connection is released via :ref:`datasette_close`. + +.. admonition:: Startup hooks run on the event loop that serves requests + + In every trigger path below, ``startup`` hooks run on the same ``asyncio`` event loop that goes on to accept connections. It is safe to create loop-bound primitives — ``asyncio.Lock``, ``asyncio.Queue``, ``asyncio.Event``, a raw ``asyncio.create_task()`` call — inside a ``startup`` hook, and to register long-lived background work with :ref:`datasette_add_background_task` there. This was not always true: older Datasette versions ran startup on a temporary event loop in the CLI that was closed before the server's own loop was created, which could silently kill anything scheduled on it. + +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, 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). + +.. _datasette_add_background_task: + +.add_background_task(func, name=None) +------------------------------------- + +``func`` - async callable + A coroutine function taking one positional argument, the ``Datasette`` instance. Core calls ``await func(datasette)``. + +``name`` - string, optional + A name for the task, used to identify it in the ``/-/tasks`` introspection endpoint (:ref:`JsonDataView_tasks`) and in log messages. Defaults to ``func.__qualname__``. If the resulting name collides with an already-registered task, a ``-2``, ``-3``, ... suffix is appended. + +Registers a piece of supervised, long-lived background work — typically called from a :ref:`plugin_hook_startup` hook, though it can be called at any point after the instance exists, including from a request handler. Returns a :ref:`BackgroundTask ` handle. + +Registration is separate from launch. Calling this from a ``startup`` hook — the common case — buffers the task; core launches every registered task once *all* ``startup`` hooks have completed, as described in :ref:`datasette_lifecycle`. Calling it after launch has already happened — for example from a request handler, to start a per-job task dynamically — starts the task immediately instead. + +.. code-block:: python + + import asyncio + from datasette import hookimpl + + + async def poll_for_updates(datasette): + while True: + await do_one_poll(datasette) + await asyncio.sleep(60) + + + @hookimpl + def startup(datasette): + datasette.add_background_task( + poll_for_updates, name="my-plugin-poller" + ) + +Core owns the task for the rest of the process's life: + +- **A strong reference is kept forever**, so the task can never be silently garbage collected the way an unreferenced ``asyncio.create_task()`` call can be. +- **A crash is logged, not swallowed.** If ``func`` raises anything other than ``asyncio.CancelledError``, the exception (with its traceback) is logged to the ``datasette.background_tasks`` logger and recorded on the handle's ``.exception``, and the task's ``.state`` becomes ``crashed``. **There is no automatic restart in v1** — a long-running loop should catch and log its own transient errors internally if it wants to keep running after one. +- **Cancellation is coordinated.** On shutdown, every task that is still running is cancelled and given a grace period to stop — see :ref:`datasette_lifecycle`. + +Raw ``asyncio.create_task()`` inside a ``startup`` hook now works correctly, because ``startup`` hooks run on the serving event loop (see the admonition in :ref:`datasette_lifecycle`) — the bug that made this unsafe is fixed. But a task created that way is unsupervised: nothing keeps a reference to it, nothing logs its exceptions, nothing cancels it on shutdown, and it will not show up in ``/-/tasks``. Prefer ``add_background_task()`` for anything long-lived. + +Launch matrix +~~~~~~~~~~~~~ + +Whether registered tasks actually launch depends on how the instance is being run: + +.. list-table:: + :header-rows: 1 + + * - Trigger + - Launches registered tasks? + * - ASGI lifespan (real server deployments) + - Yes, after ``lifespan.startup`` completes + * - First-request fallback (lifespan-less hosts) + - Yes, on the first request — parity with the lifespan case + * - ``datasette serve --get`` + - Never + * - Tests / headless embedders + - Only if you call :ref:`datasette_start_background_tasks` explicitly + +``datasette --get`` never launches background tasks, even though its one-shot request flows through the same first-request fallback as everything else: it sets an internal flag before making that request specifically to suppress the launch, since a one-shot CLI invocation has no server loop left running afterwards to keep any launched tasks alive. + +.. _BackgroundTask: + +BackgroundTask objects +~~~~~~~~~~~~~~~~~~~~~~ + +``add_background_task()`` returns a ``BackgroundTask`` handle with the following attributes: + +``.name`` - string + The task's (unique) name. + +``.state`` - string + One of ``registered`` (added but not yet launched), ``running``, ``completed`` (returned cleanly), ``crashed`` (raised an exception) or ``cancelled``. + +``.task`` - ``asyncio.Task`` or ``None`` + The underlying ``asyncio.Task``, once launched. ``None`` while still ``registered``. + +``.exception`` - ``BaseException`` or ``None`` + The exception that crashed the task, if ``.state`` is ``crashed``. + +``.started_at`` - string or ``None`` + ISO 8601 UTC timestamp of when the task was launched. + +``.plugin`` - string or ``None`` + Best-effort name of the plugin that registered the task, resolved from the module ``func`` was defined in. Used by ``/-/tasks`` and log messages; ``None`` if it cannot be determined. + +``.cancel()`` + Cancel the task. If it has already launched, this cancels the underlying ``asyncio.Task`` — ``.state`` becomes ``cancelled`` once the cancellation is observed. If it has not launched yet, it is removed from the queue so it never runs. + +This is also the shape of each entry returned by the ``/-/tasks`` JSON introspection endpoint — see :ref:`JsonDataView_tasks`. + +.. _datasette_start_background_tasks: + +await .start_background_tasks() +------------------------------- + +Runs startup (if it has not already run) and launches every task registered with :ref:`datasette_add_background_task`. This is the explicit equivalent of what happens automatically via ASGI lifespan or the first-request fallback in a served deployment — the entry point for tests and headless embedders (a cron-style CLI command that wants supervised background work without running a server) that need background tasks without going through either of those paths. + +.. code-block:: python + + datasette = Datasette(memory=True) + await datasette.start_background_tasks() + +.. note:: + + ``start_background_tasks()`` calls ``invoke_startup()`` internally, **not** the fuller startup sequence a served instance uses — so calling it directly, without a prior request through ``datasette.client``, skips the immutable-database table-count precompute that a real server performs as part of startup. This only matters if your code inspects table counts before any request has been made; if you also exercise the instance via ``datasette.client`` (which arms the first-request fallback, and therefore the full startup sequence including table counts), or don't care about table counts up front, there is nothing to worry about. .. _datasette_track_event: diff --git a/docs/introspection.rst b/docs/introspection.rst index 21d2de05..222dc5fc 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -284,7 +284,9 @@ Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``per -------- Shows the state of every supervised background task registered with -``datasette.add_background_task()``. This endpoint requires +:ref:`datasette.add_background_task() `; see also +:ref:`BackgroundTask ` for what each field below means, and +:ref:`datasette_lifecycle` for when tasks are launched. This endpoint requires the ``permissions-debug`` permission, since a crashed task's ``exception`` field can reveal internals such as file paths or query text: @@ -320,11 +322,12 @@ skimmable. The top-level ``launched`` flag reports whether the instance has run its one-time background task launch (after ``startup`` hooks finish, or via -lifespan/first-request/``start_background_tasks()``). It distinguishes "no -tasks have been registered" (``tasks`` is empty either way) from "tasks are -registered but nothing has armed the launch yet" (``launched`` is -``false`` and every task's ``state`` is still ``registered``) - useful when -debugging a host that never triggers Datasette's lifespan events. +lifespan/first-request/:ref:`start_background_tasks() `). +It distinguishes "no tasks have been registered" (``tasks`` is empty either +way) from "tasks are registered but nothing has armed the launch yet" +(``launched`` is ``false`` and every task's ``state`` is still +``registered``) - useful when debugging a host that never triggers +Datasette's lifespan events. .. _JsonDataView_actor: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index c1836978..a1537ef1 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1157,7 +1157,7 @@ Examples: `datasette-cors `__, `dat startup(datasette) ------------------ -This hook fires when the Datasette application server first starts up. +This hook fires when the Datasette application server first starts up. It runs on the same event loop that goes on to serve requests, so it is safe to create loop-bound primitives and register background work here — see :ref:`datasette_lifecycle` for the full guarantee and the three ways startup can be triggered. Here is an example that validates required plugin configuration. The server will fail to start and show an error if the validation check fails: @@ -1195,6 +1195,7 @@ Potential use-cases: * Create database tables that a plugin needs on startup * Validate the configuration for a plugin on startup, and raise an error if it is invalid * Raise a ``datasette.utils.StartupError("message")`` exception to prevent Datasette from starting and display that message to the user. +* Register supervised long-lived background work using :ref:`datasette_add_background_task`, which core launches once every plugin's ``startup()`` hook has finished. .. note:: @@ -1220,7 +1221,7 @@ This hook fires once, when the Datasette application server is shutting down gra Like ``startup()``, this can be a regular function or it can return an async function to be awaited. -It runs before Datasette cancels any background tasks it is supervising and before it closes its database connections, so you can use it to tell your plugin's own background work to stop gracefully while a database connection is still available to write out any final state: +It runs before Datasette cancels any background tasks it is supervising (see :ref:`datasette_add_background_task`) and before it closes its database connections, so you can use it to tell your plugin's own background work to stop gracefully while a database connection is still available to write out any final state. See :ref:`datasette_lifecycle` for exactly where this fits into the full startup-to-shutdown sequence: .. code-block:: python diff --git a/docs/testing_plugins.rst b/docs/testing_plugins.rst index 15891963..2454c0a4 100644 --- a/docs/testing_plugins.rst +++ b/docs/testing_plugins.rst @@ -78,9 +78,19 @@ Creating a ``Datasette()`` instance like this as useful shortcut in tests, but t datasette = Datasette(memory=True) await datasette.invoke_startup() -This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepare_jinja2_environment` plugins that might themselves need to make async calls. +This method registers any :ref:`plugin_hook_startup` or :ref:`plugin_hook_prepare_jinja2_environment` plugins that might themselves need to make async calls. It runs on the same event loop that runs your test, matching the guarantee described in :ref:`datasette_lifecycle`. -If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request. +If you are using ``await datasette.client.get()`` and similar methods then you don't need to worry about this - Datasette automatically calls ``invoke_startup()`` the first time it handles a request, via the first-request fallback described in :ref:`datasette_lifecycle`. + +If your plugin also registers work with :ref:`datasette_add_background_task` (typically from a ``startup`` hook) and your test needs that work to actually run, call ``await datasette.start_background_tasks()`` as well - ``invoke_startup()`` alone only runs ``startup`` hooks, it does not launch anything they registered: + +.. code-block:: python + + datasette = Datasette(memory=True) + await datasette.start_background_tasks() + # Any tasks registered by a startup() hook are now running + +A request made through ``datasette.client`` arms both startup and background-task launch automatically, since they're both part of the same first-request fallback - ``start_background_tasks()`` is for tests that need tasks running without making an HTTP request first. .. _testing_plugins_datasette_fixtures_database: