Add /-/tasks introspection endpoint for supervised background tasks (#2892)

Co-authored-by: Simon Willison <swillison@gmail.com>
This commit is contained in:
Alex Garcia 2026-09-15 11:56:53 -07:00 committed by GitHub
commit 374b194ff5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 218 additions and 52 deletions

View file

@ -2466,6 +2466,21 @@ ORDER BY allowed.parent, allowed.child
)
return d
def _tasks(self):
return {
"tasks": [
{
"name": t.name,
"state": t.state,
"function": t.function,
"started_at": t.started_at,
"exception": repr(t.exception) if t.exception else None,
}
for t in self._background_tasks.tasks()
],
"launched": self._background_tasks.launched,
}
def _actor(self, request):
return {"actor": request.actor}
@ -2754,6 +2769,12 @@ ORDER BY allowed.parent, allowed.child
),
r"/-/threads(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self, "tasks.json", self._tasks, permission="permissions-debug"
),
r"/-/tasks(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self,
@ -3039,7 +3060,7 @@ ORDER BY allowed.parent, allowed.child
Returns a :class:`~datasette.background_tasks.BackgroundTask`
handle (``.name``, ``.state``, ``.task``, ``.exception``,
``.started_at``, ``.plugin``, ``.cancel()``).
``.started_at``, ``.function``, ``.cancel()``).
``name`` defaults to ``func.__qualname__``; on a name collision a
``-2``, ``-3``, ... suffix is appended, since names are how

View file

@ -29,7 +29,6 @@ from __future__ import annotations
import asyncio
import datetime
import functools
import inspect
import logging
from collections.abc import Awaitable, Callable
@ -40,46 +39,13 @@ def _utcnow_iso() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def _resolve_plugin_name(func: Callable) -> str | None:
"""Best-effort, cheap attempt to work out which registered plugin a
background-task function belongs to, for the ``.plugin`` field on
:class:`BackgroundTask` (used by ``/-/tasks`` and logs).
This matches ``func``'s module against every currently-registered
pluggy plugin's module - the same module a plugin's ``startup`` hook
implementation lives in, in the overwhelmingly common case where
``add_background_task`` is called directly from (or a couple of
frames below) that hook. It deliberately does *not* walk the call
stack or otherwise try harder: this is a nice-to-have for
introspection, not something worth building heavy machinery for, and
returning ``None`` when it can't tell is a fine fallback.
"""
try:
from .plugins import pm
module = inspect.getmodule(func)
if module is None:
return None
module_name = getattr(module, "__name__", None)
if not module_name:
return None
for plugin in pm.get_plugins():
plugin_module = (
plugin if inspect.ismodule(plugin) else inspect.getmodule(plugin)
)
if plugin_module is None:
continue
plugin_module_name = getattr(plugin_module, "__name__", None)
if not plugin_module_name:
continue
if module_name == plugin_module_name or module_name.startswith(
plugin_module_name + "."
):
return pm.get_name(plugin)
except Exception: # noqa: BLE001
# Never let plugin-name resolution break task registration.
return None
return None
def _function_path(func: Callable) -> str:
"""Describe the callable without guessing which plugin registered it."""
while isinstance(func, functools.partial):
func = func.func
if not hasattr(func, "__qualname__"):
func = type(func).__call__
return f"{func.__module__}.{func.__qualname__}"
class BackgroundTask:
@ -96,14 +62,13 @@ class BackgroundTask:
self,
name: str,
func: Callable[[object], Awaitable[None]],
plugin: str | None = None,
):
self.name = name
self.state = "registered"
self.task: asyncio.Task | None = None
self.exception: BaseException | None = None
self.started_at: str | None = None
self.plugin = plugin
self.function = _function_path(func)
self._func = func
self._supervisor: BackgroundTaskSupervisor | None = None
@ -160,8 +125,7 @@ class BackgroundTaskSupervisor:
def add(self, func, name=None) -> BackgroundTask:
base_name = name or getattr(func, "__qualname__", None) or repr(func)
actual_name = self._unique_name(base_name)
plugin = _resolve_plugin_name(func)
handle = BackgroundTask(actual_name, func, plugin=plugin)
handle = BackgroundTask(actual_name, func)
handle._supervisor = self
self._tasks.append(handle)
self._names.add(actual_name)
@ -240,6 +204,15 @@ class BackgroundTaskSupervisor:
"""
return list(self._tasks)
@property
def launched(self) -> bool:
"""Whether :meth:`launch_all` has run yet - lets ``/-/tasks``
distinguish "no tasks registered" from "tasks registered but
nothing has armed the launch yet" without reaching for the
private ``_launched`` attribute.
"""
return self._launched
def _on_task_done(handle: BackgroundTask, task: asyncio.Task) -> None:
if task.cancelled():

View file

@ -12,6 +12,17 @@ Unreleased
- Fixed incorrect counts when clicking **count all** on filtered table pages. The button now uses a new :ref:`POST count endpoint <TableCountView>`. (:issue:`2914`)
- Datasette now uses `httpx2 <https://httpx2.pydantic.dev/>`__, the Pydantic-maintained continuation of `httpx <https://www.python-httpx.org/>`__, in place of ``httpx``. The public API is the same, but responses returned by :ref:`internals_datasette_client` are now ``httpx2.Response`` objects rather than ``httpx.Response``. Plugins that use ``isinstance()`` checks against ``httpx.Response`` should be updated to use ``httpx2``. **Plugins that use httpx without explicitly depending on it** will need to add an explicit dependency or switch to `httpx2`.
Background tasks
~~~~~~~~~~~~~~~~
Datasette plugins can now use **background tasks** to run code independent of the Datasette request/response cycle.
- New :ref:`datasette_add_background_task` API: plugins register supervised, long-lived background work - typically from a ``startup`` hook - and these will be launched after every ``startup`` hook has run. Tasks are cancelled (with a five-second grace period) on shutdown.
- 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.
- 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. It is not called on a hard kill (``SIGKILL``).
- Plugin ``asgi_wrapper`` middleware now always runs *after* startup has completed.
- If your plugin uses ``asgi_wrapper`` to start background tasks on the first incoming request, you should migrate to ``datasette.add_background_task()`` instead. `datasette-cron <https://datasette.io/plugins/datasette-cron>`__ and `datasette-enrichments <https://datasette.io/plugins/datasette-enrichments>`__ are being migrated to this pattern.
.. _v1_0_a39:
1.0a39 (2026-09-10)

View file

@ -1440,7 +1440,7 @@ All three paths call the same idempotent internal methods, so it is safe for mor
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 log messages. Defaults to ``func.__qualname__``. If the resulting name collides with an already-registered task, a ``-2``, ``-3``, ... suffix is appended.
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 <BackgroundTask>` handle.
@ -1470,8 +1470,6 @@ Core owns the task for the rest of the process's life:
- **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``.
- **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, and nothing cancels it on shutdown. Prefer ``add_background_task()`` for anything long-lived.
Launch matrix
~~~~~~~~~~~~~
@ -1515,12 +1513,14 @@ BackgroundTask objects
``.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 in log messages; ``None`` if it cannot be determined.
``.function`` - string
The callable's dotted module and qualified name, for example ``my_plugin.jobs.poll_for_updates``.
``.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()

View file

@ -9,7 +9,7 @@ Each of these pages can be viewed in your browser. Add ``.json`` to the URL to g
JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API <json_api>`.
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads`` and ``/-/actions``, whose shapes may change in future releases.
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads``, ``/-/tasks`` and ``/-/actions``, whose shapes may change in future releases.
.. _JsonDataView_metadata:
@ -278,6 +278,42 @@ Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``per
]
}
.. _JsonDataView_tasks:
/-/tasks
--------
Shows the state of every supervised background task registered with :ref:`datasette.add_background_task() <datasette_add_background_task>`; see also :ref:`BackgroundTask <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:
.. code-block:: json
{
"ok": true,
"tasks": [
{
"name": "my_plugin.poll_for_updates",
"state": "running",
"function": "my_plugin.poll_for_updates",
"started_at": "2026-07-30T12:00:00+00:00",
"exception": null
},
{
"name": "my_plugin.broken_task",
"state": "crashed",
"function": "my_plugin.broken_task",
"started_at": "2026-07-30T12:00:00+00:00",
"exception": "ValueError('something went wrong')"
}
],
"launched": true
}
Each entry's ``function`` identifies the callable by its dotted module and qualified name.
Each entry's ``state`` is one of ``registered`` (added but not yet launched), ``running``, ``completed``, ``crashed`` or ``cancelled``. ``exception`` is a one-line ``repr()`` of the exception for a ``crashed`` task, or ``null`` otherwise - the full traceback is written to the ``datasette.background_tasks`` logger instead, to keep this payload 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/:ref:`start_background_tasks() <datasette_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:
/-/actor

View file

@ -48,6 +48,7 @@ Some JSON endpoints are **exempt** from this promise:
debug playground.
- Debug and support endpoints are documented so you can use them, but their
JSON shapes are not frozen: :ref:`/-/threads <JsonDataView_threads>`,
:ref:`/-/tasks <JsonDataView_tasks>`,
:ref:`/-/actions <JsonDataView_actions>`,
the :ref:`permission debug endpoints <PermissionsDebugView>`
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the

View file

@ -520,6 +520,7 @@ def view_instance_client():
"/-/plugins",
"/-/settings",
"/-/threads",
"/-/tasks",
"/-/databases",
"/-/permissions",
"/-/messages",

View file

@ -59,6 +59,7 @@ async def test_success_object_has_ok_true(ds_client, path):
"/-/rules.json?action=view-instance",
"/-/check.json?action=view-instance",
"/-/threads.json",
"/-/tasks.json",
),
)
async def test_permission_debug_success_has_ok_true(ds_envelope, path):

View file

@ -0,0 +1,122 @@
"""
Tests for the /-/tasks introspection endpoint.
/-/tasks exposes datasette._background_tasks (see tests/test_background_tasks.py
for the supervisor machinery itself) the same way /-/threads exposes threading
internals: gated behind the permissions-debug permission, JSON-only.
"""
import asyncio
import contextlib
import functools
import pytest
from datasette.app import Datasette
async def example_task(datasette):
pass
class ExampleWorker:
async def run(self, datasette):
pass
async def __call__(self, datasette):
pass
@pytest.mark.asyncio
@pytest.mark.parametrize(
"func, qualified_name",
[
(example_task, "example_task"),
(functools.partial(example_task), "example_task"),
(ExampleWorker().run, "ExampleWorker.run"),
(ExampleWorker(), "ExampleWorker.__call__"),
],
)
async def test_task_function_path(func, qualified_name):
ds = Datasette(memory=True)
ds.root_enabled = True
handle = ds.add_background_task(func, name="custom-name")
try:
response = await ds.client.get("/-/tasks.json", actor={"id": "root"})
assert response.status_code == 200
task = response.json()["tasks"][0]
assert task["name"] == "custom-name"
assert task["function"] == f"{__name__}.{qualified_name}"
assert handle.function == task["function"]
assert "plugin" not in task
await handle.task
html = await ds.client.get("/-/tasks", actor={"id": "root"})
assert html.status_code == 200
assert task["function"] in html.text
finally:
await ds.invoke_shutdown()
@pytest.mark.asyncio
async def test_tasks_requires_permissions_debug():
ds = Datasette(memory=True)
ds.root_enabled = True
denied = await ds.client.get("/-/tasks.json")
assert denied.status_code == 403
allowed = await ds.client.get("/-/tasks.json", actor={"id": "root"})
assert allowed.status_code == 200
data = allowed.json()
assert data["ok"] is True
assert "tasks" in data
assert "launched" in data
@pytest.mark.asyncio
async def test_running_and_crashed_task_states():
ds = Datasette(memory=True)
ds.root_enabled = True
async def long_running(datasette):
await asyncio.Event().wait()
async def crashing_task(datasette):
raise RuntimeError("kaboom")
long_handle = ds.add_background_task(long_running, name="long-runner")
crash_handle = ds.add_background_task(crashing_task, name="crashing_task")
await ds.start_background_tasks()
# Let the crashing_task run to completion and its done-callback (which sets
# handle.state = "crashed") actually fire before we read state back out.
await asyncio.wait_for(
asyncio.gather(crash_handle.task, return_exceptions=True), timeout=5
)
await asyncio.sleep(0)
try:
response = await ds.client.get("/-/tasks.json", actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert data["launched"] is True
by_name = {t["name"]: t for t in data["tasks"]}
assert by_name["long-runner"]["state"] == "running"
assert by_name["long-runner"]["exception"] is None
assert by_name["long-runner"]["started_at"] is not None
crashed = by_name["crashing_task"]
assert crashed["function"] == (
f"{__name__}.test_running_and_crashed_task_states.<locals>.crashing_task"
)
assert crashed["state"] == "crashed"
assert crashed["exception"] is not None
assert isinstance(crashed["exception"], str)
assert "kaboom" in crashed["exception"]
assert "RuntimeError" in crashed["exception"]
finally:
long_handle.cancel()
with contextlib.suppress(asyncio.CancelledError):
await long_handle.task