Add shutdown() plugin hook with ordered graceful teardown (#2890)

This commit is contained in:
Alex Garcia 2026-09-15 11:09:41 -07:00 committed by Simon Willison
commit cca08d2886
7 changed files with 454 additions and 5 deletions

View file

@ -1416,7 +1416,7 @@ Datasette guarantees a fixed sequence of events between the moment a ``Datasette
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 still-running background task is cancelled and given a five-second grace period to actually stop; finally every database connection is released via :ref:`datasette_close`.
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

View file

@ -1212,6 +1212,31 @@ Potential use-cases:
Examples: `datasette-saved-queries <https://datasette.io/plugins/datasette-saved-queries>`__, `datasette-init <https://datasette.io/plugins/datasette-init>`__
.. _plugin_hook_shutdown:
shutdown(datasette)
-------------------
This hook fires once, when the Datasette application server is shutting down gracefully - triggered by the ASGI ``lifespan.shutdown`` event, which includes pressing Ctrl-C or sending ``SIGTERM`` to a ``datasette serve`` process. It is not called on a hard kill (``SIGKILL``), since there is no opportunity to run any code in that case.
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 (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
@hookimpl
def shutdown(datasette):
async def inner():
db = datasette.get_database()
await db.execute_write(
"insert into shutdown_log (at) values (datetime('now'))"
)
return inner
If your ``shutdown()`` hook raises an exception it will be logged but not re-raised, so one plugin's broken shutdown code cannot prevent other plugins - or Datasette itself - from finishing their own teardown.
.. _plugin_hook_actor_from_request:
actor_from_request(datasette, request)