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 .column_types import SQLiteType
from .csrf import CrossOriginProtectionMiddleware from .csrf import CrossOriginProtectionMiddleware
from .database import Database, QueryInterrupted from .database import Database, QueryInterrupted
from .events import Event from .events import AddDatabaseEvent, Event, RemoveDatabaseEvent
from .plugins import DEFAULT_PLUGINS, get_plugins, pm from .plugins import DEFAULT_PLUGINS, get_plugins, pm
from .renderer import json_renderer from .renderer import json_renderer
from .resources import DatabaseResource, TableResource from .resources import DatabaseResource, TableResource
@ -423,6 +423,9 @@ class Datasette:
): ):
self._startup_invoked = False self._startup_invoked = False
self._closed = 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( assert config_dir is None or isinstance(
config_dir, Path config_dir, Path
), "config_dir= should be a pathlib.Path" ), "config_dir= should be a pathlib.Path"
@ -935,6 +938,14 @@ class Datasette:
new_databases[name] = db new_databases[name] = db
# don't mutate! that causes race conditions with live import # don't mutate! that causes race conditions with live import
self.databases = new_databases 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 return db
def add_memory_database(self, memory_name, name=None, route=None): def add_memory_database(self, memory_name, name=None, route=None):
@ -943,10 +954,41 @@ class Datasette:
) )
def remove_database(self, name): 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 = self.databases.copy()
new_databases.pop(name) new_databases.pop(name)
self.databases = new_databases 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): def close(self):
"""Release all resources held by this Datasette instance. """Release all resources held by this Datasette instance.

View file

@ -241,6 +241,54 @@ class DeleteRowEvent(Event):
pks: list 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 @hookimpl
def write_wrapper(datasette, database, request, transaction): def write_wrapper(datasette, database, request, transaction):
def wrapper(conn, track_event): def wrapper(conn, track_event):
@ -291,4 +339,6 @@ def register_events():
UpsertRowsEvent, UpsertRowsEvent,
UpdateRowEvent, UpdateRowEvent,
DeleteRowEvent, 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>`. 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} ```{eval-rst}
.. automodule:: datasette.events .. automodule:: datasette.events
:members: :members:

View file

@ -1357,6 +1357,8 @@ Use ``is_mutable=False`` to add an immutable database.
"CREATE TABLE foo(id integer primary key)" "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: .. _datasette_add_memory_database:
.add_memory_database(memory_name, name=None, route=None) .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. 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: .. _datasette_close:
.close() .close()

View file

@ -476,3 +476,98 @@ async def test_datasette_render_template_dataclass_values_not_deep_copied():
await ds.invoke_startup() await ds.invoke_startup()
rendered = await ds.render_template("error.html", context) rendered = await ds.render_template("error.html", context)
assert "shallow-copied-value" in rendered 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) datasette = Datasette(memory=True)
await datasette.invoke_startup() await datasette.invoke_startup()
assert any(k.__name__ == "OneEvent" for k in datasette.event_classes) 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 @pytest.mark.asyncio