Compare commits

...

1 commit

Author SHA1 Message Date
Alex Garcia
c6bbcc3298 Add add-database and remove-database lifecycle events
Fire plugin-visible events from Datasette.add_database() and
.remove_database() so plugins can react to databases being attached or
detached at runtime. Dispatch is best-effort fire-and-forget: events only
fire when startup has completed and an event loop is running, and
remove-database fires after close() so queued writes are flushed before
listeners run.

Motivating consumer is datasette-litestream, which needs to register newly
attached databases with its replication daemon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:43:20 -07:00
6 changed files with 205 additions and 2 deletions

View file

@ -45,7 +45,7 @@ from . import stored_queries, write_sql
from .column_types import SQLiteType
from .csrf import CrossOriginProtectionMiddleware
from .database import Database, QueryInterrupted
from .events import Event
from .events import AddDatabaseEvent, Event, RemoveDatabaseEvent
from .plugins import DEFAULT_PLUGINS, get_plugins, pm
from .renderer import json_renderer
from .resources import DatabaseResource, TableResource
@ -423,6 +423,9 @@ class Datasette:
):
self._startup_invoked = False
self._closed = False
# Strong references to in-flight fire-and-forget event dispatch
# tasks, so they cannot be garbage-collected before completing
self._pending_event_tasks = set()
assert config_dir is None or isinstance(
config_dir, Path
), "config_dir= should be a pathlib.Path"
@ -935,6 +938,14 @@ class Datasette:
new_databases[name] = db
# don't mutate! that causes race conditions with live import
self.databases = new_databases
self._track_event_soon(
AddDatabaseEvent(
actor=None,
database=db.name,
path=str(Path(db.path).resolve()) if db.path else None,
is_memory=db.is_memory,
)
)
return db
def add_memory_database(self, memory_name, name=None, route=None):
@ -943,10 +954,41 @@ class Datasette:
)
def remove_database(self, name):
self.get_database(name).close()
db = self.get_database(name)
# Capture event details before close() - is_temp_disk databases
# delete their backing file during close()
path = str(Path(db.path).resolve()) if db.path else None
is_memory = db.is_memory
# Fire the event only after close() returns: close() drains any
# queued writes, so listeners doing a final read of the file see
# everything
db.close()
new_databases = self.databases.copy()
new_databases.pop(name)
self.databases = new_databases
self._track_event_soon(
RemoveDatabaseEvent(
actor=None,
database=name,
path=path,
is_memory=is_memory,
)
)
def _track_event_soon(self, event):
# Best-effort fire-and-forget event dispatch from synchronous code.
# If startup has not run (event classes are not yet registered) or
# there is no running event loop, the event is intentionally
# dropped - lifecycle events are documented as runtime-only
if not self._startup_invoked:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
task = loop.create_task(self.track_event(event))
self._pending_event_tasks.add(task)
task.add_done_callback(self._pending_event_tasks.discard)
def close(self):
"""Release all resources held by this Datasette instance.

View file

@ -241,6 +241,54 @@ class DeleteRowEvent(Event):
pks: list
@dataclass
class AddDatabaseEvent(Event):
"""
Event name: ``add-database``
A new database was attached to this Datasette instance while it was
running, using :ref:`datasette.add_database() <datasette_add_database>`.
:ivar database: The name the database was attached as.
:type database: str
:ivar path: Resolved absolute filesystem path to the database file, or ``None`` for in-memory databases.
:type path: str or None
:ivar is_memory: ``True`` if this is an in-memory database.
:type is_memory: bool
"""
name = "add-database"
database: str
path: str | None
is_memory: bool
@dataclass
class RemoveDatabaseEvent(Event):
"""
Event name: ``remove-database``
A database was detached from this Datasette instance using
:ref:`datasette.remove_database() <datasette_remove_database>`. The
database file itself is not deleted by Datasette core, and any queued
writes have been flushed by the time this event is delivered - with one
exception: temporary on-disk databases remove their backing file when
they are closed, so for those the path in this event no longer exists.
:ivar database: The name the database was attached as.
:type database: str
:ivar path: Resolved absolute filesystem path to the database file, or ``None`` for in-memory databases.
:type path: str or None
:ivar is_memory: ``True`` if this was an in-memory database.
:type is_memory: bool
"""
name = "remove-database"
database: str
path: str | None
is_memory: bool
@hookimpl
def write_wrapper(datasette, database, request, transaction):
def wrapper(conn, track_event):
@ -291,4 +339,6 @@ def register_events():
UpsertRowsEvent,
UpdateRowEvent,
DeleteRowEvent,
AddDatabaseEvent,
RemoveDatabaseEvent,
]

View file

@ -9,6 +9,15 @@ Note that these events will *not* fire for changes made to a SQLite database by
Plugins can listen for events using the {ref}`plugin_hook_track_event` plugin hook, which will be called with instances of the following classes - or additional classes {ref}`registered by other plugins <plugin_hook_register_events>`.
## Delivery guarantees for database lifecycle events
The ``add-database`` and ``remove-database`` events have some specific delivery characteristics:
- Delivery is asynchronous. Listeners run shortly after the change, not before the triggering ``add_database()`` or ``remove_database()`` call returns.
- These events fire only for changes made at runtime - while an event loop is running, after Datasette's startup has completed. Databases attached while the instance is starting up do not produce events: plugins that need to see those should iterate over ``datasette.databases`` in their own {ref}`plugin_hook_startup` hook.
- Rapid successive changes involving the same database name may reach listeners interleaved. Listeners should tolerate events arriving out of order.
- ``event.actor`` is ``None`` for programmatic calls made by Datasette itself or by plugins.
```{eval-rst}
.. automodule:: datasette.events
:members:

View file

@ -1357,6 +1357,8 @@ Use ``is_mutable=False`` to add an immutable database.
"CREATE TABLE foo(id integer primary key)"
)
Calling this method while the instance is running - after ``invoke_startup()`` has completed, with an event loop running - emits an ``add-database`` :ref:`event <events>`. Databases attached during startup do not emit events: plugins that need to see those should iterate over ``datasette.databases`` in their own :ref:`plugin_hook_startup` hook.
.. _datasette_add_memory_database:
.add_memory_database(memory_name, name=None, route=None)
@ -1392,6 +1394,8 @@ The ``name`` and ``route`` parameters are optional and work the same way as they
This removes a database that has been previously added. ``name=`` is the unique name of that database.
The database is closed but its file is not deleted. When called while the instance is running this emits a ``remove-database`` :ref:`event <events>` after the database has been closed - since closing flushes any queued writes first, an event listener can safely perform a final read of the database file. The exception is temporary on-disk databases, which remove their backing file when closed.
.. _datasette_close:
.close()

View file

@ -476,3 +476,98 @@ async def test_datasette_render_template_dataclass_values_not_deep_copied():
await ds.invoke_startup()
rendered = await ds.render_template("error.html", context)
assert "shallow-copied-value" in rendered
def _lifecycle_events(ds):
return [
event
for event in getattr(ds, "_tracked_events", [])
if event.name in ("add-database", "remove-database")
]
async def _drain_event_tasks(ds):
await asyncio.gather(*ds._pending_event_tasks)
@pytest.mark.asyncio
async def test_add_database_fires_event(tmp_path):
ds = Datasette(memory=True)
await ds.invoke_startup()
path = str(tmp_path / "data.db")
sqlite3.connect(path).execute("vacuum")
db = ds.add_database(Database(ds, path=path, is_mutable=True))
await _drain_event_tasks(ds)
events = _lifecycle_events(ds)
assert len(events) == 1
event = events[0]
assert event.name == "add-database"
assert event.database == db.name == "data"
assert event.path == os.path.abspath(path)
assert event.is_memory is False
assert event.actor is None
@pytest.mark.asyncio
async def test_add_memory_database_fires_event():
ds = Datasette(memory=True)
await ds.invoke_startup()
ds.add_memory_database("test_add_memory_database_event")
await _drain_event_tasks(ds)
events = _lifecycle_events(ds)
assert len(events) == 1
event = events[0]
assert event.name == "add-database"
assert event.database == "test_add_memory_database_event"
assert event.path is None
assert event.is_memory is True
@pytest.mark.asyncio
async def test_remove_database_fires_event(tmp_path):
ds = Datasette(memory=True)
await ds.invoke_startup()
path = str(tmp_path / "data.db")
sqlite3.connect(path).execute("vacuum")
db = ds.add_database(Database(ds, path=path, is_mutable=True))
ds.remove_database(db.name)
await _drain_event_tasks(ds)
events = _lifecycle_events(ds)
assert [event.name for event in events] == ["add-database", "remove-database"]
event = events[1]
assert event.database == "data"
assert event.path == os.path.abspath(path)
assert event.is_memory is False
assert event.actor is None
# remove_database never deletes the file
assert os.path.exists(path)
@pytest.mark.asyncio
async def test_add_database_no_event_before_startup():
ds = Datasette(memory=True)
# invoke_startup() has not run - no event, and no AssertionError from
# track_event()'s event_classes check
ds.add_database(Database(ds, memory_name="pre_startup_db"))
assert ds._pending_event_tasks == set()
assert _lifecycle_events(ds) == []
def test_add_database_no_event_without_running_loop():
ds = Datasette(memory=True)
asyncio.run(ds.invoke_startup())
# Startup has run but there is no running event loop now
ds.add_database(Database(ds, memory_name="no_loop_db"))
assert ds._pending_event_tasks == set()
assert _lifecycle_events(ds) == []
@pytest.mark.asyncio
async def test_add_database_event_uses_renamed_name():
ds = Datasette(memory=True)
await ds.invoke_startup()
ds.add_memory_database("first_mem", name="clash")
ds.add_memory_database("second_mem", name="clash")
await _drain_event_tasks(ds)
events = _lifecycle_events(ds)
assert [event.database for event in events] == ["clash", "clash_2"]

View file

@ -1661,6 +1661,9 @@ async def test_hook_register_events():
datasette = Datasette(memory=True)
await datasette.invoke_startup()
assert any(k.__name__ == "OneEvent" for k in datasette.event_classes)
# Core database lifecycle events should be registered too
registered_names = {k.name for k in datasette.event_classes}
assert {"add-database", "remove-database"} <= registered_names
@pytest.mark.asyncio