mirror of
https://github.com/simonw/datasette.git
synced 2026-09-03 07:04:16 +02:00
Add /-/tasks introspection endpoint for supervised background tasks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c2f773528e
commit
19933186ac
7 changed files with 194 additions and 1 deletions
|
|
@ -2285,6 +2285,21 @@ class Datasette:
|
|||
)
|
||||
return d
|
||||
|
||||
def _tasks(self):
|
||||
return {
|
||||
"tasks": [
|
||||
{
|
||||
"name": t.name,
|
||||
"state": t.state,
|
||||
"plugin": t.plugin,
|
||||
"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}
|
||||
|
||||
|
|
@ -2573,6 +2588,12 @@ class Datasette:
|
|||
),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -240,6 +240,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():
|
||||
|
|
|
|||
|
|
@ -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,54 @@ 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
|
||||
``datasette.add_background_task()``. 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",
|
||||
"plugin": "my-plugin",
|
||||
"started_at": "2026-07-30T12:00:00+00:00",
|
||||
"exception": null
|
||||
},
|
||||
{
|
||||
"name": "my_plugin.broken_task",
|
||||
"state": "crashed",
|
||||
"plugin": "my-plugin",
|
||||
"started_at": "2026-07-30T12:00:00+00:00",
|
||||
"exception": "ValueError('something went wrong')"
|
||||
}
|
||||
],
|
||||
"launched": true
|
||||
}
|
||||
|
||||
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/``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):
|
||||
|
|
|
|||
112
tests/test_tasks_endpoint.py
Normal file
112
tests/test_tasks_endpoint.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Tests for the /-/tasks introspection endpoint (todos/first-request/06-tasks-endpoint.md).
|
||||
|
||||
/-/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 pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
|
||||
|
||||
@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 crasher(datasette):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
long_handle = ds.add_background_task(long_running, name="long-runner")
|
||||
crash_handle = ds.add_background_task(crasher, name="crasher")
|
||||
|
||||
await ds.start_background_tasks()
|
||||
|
||||
# Let the crasher 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["crasher"]
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_instance_launched_false_and_task_registered():
|
||||
ds = Datasette(memory=True)
|
||||
ds.root_enabled = True
|
||||
|
||||
async def task(datasette):
|
||||
pass
|
||||
|
||||
ds.add_background_task(task, name="cold-task")
|
||||
|
||||
# ds.client / httpx.ASGITransport routes through the full ASGI app,
|
||||
# including AsgiRunOnFirstRequest - the first-request fallback that
|
||||
# itself launches background tasks (_launch_background_tasks) so hosts
|
||||
# without lifespan support still get supervised tasks running. That
|
||||
# means a plain HTTP request here would launch "cold-task" before we
|
||||
# ever get a response, making the "never launched" state impossible to
|
||||
# observe over HTTP. _suppress_background_tasks is the same switch the
|
||||
# `datasette --get` CLI path sets to stop its one-shot request from
|
||||
# launching long-lived work (see _launch_background_tasks's docstring
|
||||
# in datasette/app.py) - setting it here keeps this one request from
|
||||
# arming the launch, so we can still exercise the real permission-gated
|
||||
# HTTP endpoint while asserting on a genuinely pre-launch snapshot.
|
||||
ds._suppress_background_tasks = True
|
||||
|
||||
response = await ds.client.get("/-/tasks.json", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["launched"] is False
|
||||
assert len(data["tasks"]) == 1
|
||||
task_data = data["tasks"][0]
|
||||
assert task_data["name"] == "cold-task"
|
||||
assert task_data["state"] == "registered"
|
||||
assert task_data["started_at"] is None
|
||||
assert task_data["exception"] is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue