From 374b194ff51b66859adcff95eb13576fcb29af76 Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Tue, 15 Sep 2026 11:56:53 -0700 Subject: [PATCH] Add /-/tasks introspection endpoint for supervised background tasks (#2892) Co-authored-by: Simon Willison --- datasette/app.py | 23 ++++++- datasette/background_tasks.py | 63 +++++------------ docs/changelog.rst | 11 +++ docs/internals.rst | 10 +-- docs/introspection.rst | 38 +++++++++- docs/json_api.rst | 1 + tests/test_permissions.py | 1 + tests/test_success_envelope.py | 1 + tests/test_tasks_endpoint.py | 122 +++++++++++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 52 deletions(-) create mode 100644 tests/test_tasks_endpoint.py diff --git a/datasette/app.py b/datasette/app.py index e1321db8..e6e3410e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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(\.(?Pjson))?$", ) + add_route( + JsonDataView.as_view( + self, "tasks.json", self._tasks, permission="permissions-debug" + ), + r"/-/tasks(\.(?Pjson))?$", + ) 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 diff --git a/datasette/background_tasks.py b/datasette/background_tasks.py index ee5e5015..6b34fd85 100644 --- a/datasette/background_tasks.py +++ b/datasette/background_tasks.py @@ -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(): diff --git a/docs/changelog.rst b/docs/changelog.rst index db5e3553..7f44f354 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 `. (:issue:`2914`) - Datasette now uses `httpx2 `__, the Pydantic-maintained continuation of `httpx `__, 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 `__ and `datasette-enrichments `__ are being migrated to this pattern. + .. _v1_0_a39: 1.0a39 (2026-09-10) diff --git a/docs/internals.rst b/docs/internals.rst index 4b646449..c05c7e27 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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 ` 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() diff --git a/docs/introspection.rst b/docs/introspection.rst index 14b6249f..5dd8e27b 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -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 `. -The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise `, 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 `, 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() `; 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: + +.. 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() `). 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 diff --git a/docs/json_api.rst b/docs/json_api.rst index 0a12fa74..73212e70 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -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 `, + :ref:`/-/tasks `, :ref:`/-/actions `, the :ref:`permission debug endpoints ` (``/-/allowed``, ``/-/rules``, ``/-/check``) and the diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 73c44682..0a77bd37 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -520,6 +520,7 @@ def view_instance_client(): "/-/plugins", "/-/settings", "/-/threads", + "/-/tasks", "/-/databases", "/-/permissions", "/-/messages", diff --git a/tests/test_success_envelope.py b/tests/test_success_envelope.py index d24a8c5d..9c95dc79 100644 --- a/tests/test_success_envelope.py +++ b/tests/test_success_envelope.py @@ -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): diff --git a/tests/test_tasks_endpoint.py b/tests/test_tasks_endpoint.py new file mode 100644 index 00000000..be174dbe --- /dev/null +++ b/tests/test_tasks_endpoint.py @@ -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..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