Skip to content

Add datasette.add_background_task() with supervised launch after startup - #2889

Open
asg017 wants to merge 5 commits into
mainfrom
asg017/first-request-3-background-tasks-api
Open

Add datasette.add_background_task() with supervised launch after startup#2889
asg017 wants to merge 5 commits into
mainfrom
asg017/first-request-3-background-tasks-api

Conversation

@asg017

@asg017 asg017 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

This PR adds a new datasette.add_background_task() API for plugins. Plugins like cron, litestream, and dozens of others often need to run code "in the background", ie not during an HTTP request or on startup. This is often stuff like montoring code, backups, etc.

import httpx2

async def heartbeat(datasette):
    while True:
        httpx.get("https://hc-ping.com/c8ea6586a5e7a")
        await asyncio.sleep(60)


@hookimpl
def startup(datasette):
    datasette.add_background_task(heartbeat,name="heartbeat")

This was possible before, but required some weird ASGI workarounds and only started after the first HTTP request. But now that #2887 is in, this works as expected.

🤖 Claude-generated PR description

Third PR in the startup/lifecycle stack, on top of #2887.

What this does

Adds datasette.add_background_task(func, name=None) — supervised background work for plugins, replacing fire-and-forget asyncio.create_task() calls in startup hooks. Core owns the task references (no silent garbage collection), the launch timing, crash surfacing, and cancellation.

Changes

  • New datasette/background_tasks.py: BackgroundTask handle (states registeredrunningcompleted/crashed/cancelled, with .exception, .started_at, .plugin, .cancel()) and BackgroundTaskSupervisor (strong references for the process lifetime, idempotent launch_all(), cancel_all() with a grace period, crash logging with full traceback to the datasette.background_tasks logger).
  • datasette/app.py: add_background_task() buffers registrations until launch, or starts immediately if launch already happened; start_background_tasks() is a public entry point for embedders and headless CLI uses; private _launch_background_tasks() is wired as the second on_startup entry in both AsgiLifespan and AsgiRunOnFirstRequest — after _startup_sequence, so launch happens only once every plugin's startup hook has had a chance to register work.
  • datasette/cli.py: --get suppresses background-task launch — its one-shot in-process request flows through the full ASGI stack but must never start long-lived work.

Docs

Rolled down from the former docs-only tip PR (#2893) so the API lands documented:

  • docs/internals.rst: full reference for add_background_task(), start_background_tasks() and BackgroundTask objects, plus a new "Application lifecycle" section (datasette_lifecycle) describing the startup → launch → serving → shutdown sequence and the three startup trigger paths. The lifecycle text covers only what exists at this point in the stack; the shutdown hook, wrapper-timing guarantee and /-/tasks cross-references are woven in by the later PRs that introduce those features.
  • docs/plugin_hooks.rst: startup() now documents the same-event-loop guarantee and the background-task use-case.
  • docs/testing_plugins.rst: how to launch registered tasks in tests via start_background_tasks().

Notes

  • Completed/crashed task handles are deliberately never pruned in this version (full introspection for the /-/tasks endpoint later in the stack); a pruning policy can come later if unbounded growth shows up in practice.
  • Task names default to func.__qualname__ with -2, -3… suffixes on collision.

Tests

New tests/test_background_tasks.py (10 tests): launch ordering relative to startup hooks, exactly-once launch across concurrent first requests, post-launch immediate start, cancellation semantics including grace-period stragglers, crash logging, name collisions, and the bare-Datasette embedder path. Plus a tests/test_cli_serve_get.py sentinel test that --get registers but never launches tasks.

Stack created with GitHub Stacks CLIGive Feedback 💬

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 133 lines in your changes missing coverage. Please review.
✅ Project coverage is 0.00%. Comparing base (bdc9731) to head (6bd0103).

Files with missing lines Patch % Lines
datasette/background_tasks.py 0.00% 118 Missing ⚠️
datasette/app.py 0.00% 13 Missing ⚠️
datasette/__init__.py 0.00% 1 Missing ⚠️
datasette/cli.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##            main   #2889    +/-   ##
======================================
  Coverage   0.00%   0.00%            
======================================
  Files         73      74     +1     
  Lines      12317   12449   +132     
======================================
- Misses     12317   12449   +132     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@simonw
simonw force-pushed the asg017/first-request-3-background-tasks-api branch from de31dc2 to 8ad0b10 Compare September 1, 2026 16:32
@simonw
simonw marked this pull request as ready for review September 1, 2026 16:38
Base automatically changed from asg017/first-request-2-lifespan-startup to main September 1, 2026 16:39
@simonw
simonw force-pushed the asg017/first-request-3-background-tasks-api branch from 8ad0b10 to 561fc3f Compare September 1, 2026 16:39
Comment thread datasette/app.py
self._setup_db_done = True
await self.invoke_startup()

def add_background_task(self, func, name=None) -> BackgroundTask:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@simonw

simonw commented Sep 1, 2026

Copy link
Copy Markdown
Owner

I had Codex generate its own docs for the new methods to help me review them: https://gist.github.com/simonw/10f35afc79e3ecb12d0d0c6f80cf873a

It wrote this example:

async def refresh_cache(datasette):
    while True:
        # Refresh the cache here
        await asyncio.sleep(60)


@hookimpl
def startup(datasette):
    datasette.add_background_task(
        refresh_cache,
        name="refresh-cache",
    )

asg017 and others added 5 commits September 2, 2026 09:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
…ion lifecycle

Rolled down from the stack's docs-only tip PR so the API lands documented.
The lifecycle section here covers only what exists at this point in the
stack; the shutdown hook, wrapper-timing guarantee and /-/tasks
cross-references are added by the later PRs that introduce those features.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
@asg017
asg017 force-pushed the asg017/first-request-3-background-tasks-api branch from 9813016 to 6bd0103 Compare September 2, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants