mirror of
https://github.com/simonw/datasette.git
synced 2026-09-16 05:24:21 +02:00
Add /-/tasks introspection endpoint for supervised background tasks (#2892)
Co-authored-by: Simon Willison <swillison@gmail.com>
This commit is contained in:
parent
784695aea6
commit
374b194ff5
9 changed files with 218 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -520,6 +520,7 @@ def view_instance_client():
|
|||
"/-/plugins",
|
||||
"/-/settings",
|
||||
"/-/threads",
|
||||
"/-/tasks",
|
||||
"/-/databases",
|
||||
"/-/permissions",
|
||||
"/-/messages",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
122
tests/test_tasks_endpoint.py
Normal file
122
tests/test_tasks_endpoint.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue