RenameTableEvent, plus write connection track_event() mechanism (#2682)

* Add track_event callback to execute_write_fn and write_wrapper

Allows write functions and write_wrapper generators to queue events
during a write operation that are dispatched after successful commit.
The fn or wrapper can optionally accept a `track_event` parameter
(detected via call_with_supported_arguments). Events are discarded
if the write raises an exception.

Does not yet handle the block=False (non-blocking) case - events
queued during non-blocking writes are currently silently discarded.

Refs https://github.com/simonw/datasette/issues/2681

* Dispatch track_event events for non-blocking (block=False) writes

Spawns a background asyncio task that awaits the write thread's reply
queue and dispatches pending events after a successful non-blocking
write. Events are still discarded if the write raises an exception.

Refs https://github.com/simonw/datasette/issues/2681

* Warn that events won't fire for other processes

Refs https://github.com/simonw/datasette/issues/2681#issuecomment-4157118662
This commit is contained in:
Simon Willison 2026-03-30 11:20:46 -07:00 committed by GitHub
commit 312f41b0c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 423 additions and 24 deletions

View file

@ -2,7 +2,9 @@
Tests for the write_wrapper plugin hook.
"""
from dataclasses import dataclass
from datasette.app import Datasette
from datasette.events import Event
from datasette.hookspecs import hookimpl
from datasette.plugins import pm
import pytest
@ -10,6 +12,12 @@ import sqlite3
import time
@dataclass
class DummyEvent(Event):
name = "dummy"
message: str
@pytest.fixture
def datasette(tmp_path):
db_path = str(tmp_path / "test.db")
@ -477,3 +485,260 @@ async def test_write_wrapper_set_authorizer(datasette, actor, table, should_deny
assert result.rows[0][0] == "test"
finally:
pm.unregister(name="test_set_authorizer")
# --- Tests for track_event callback ---
@pytest.fixture
def ds_with_event_tracking(tmp_path):
"""Datasette instance that records tracked events and registers DummyEvent."""
db_path = str(tmp_path / "test.db")
ds = Datasette([db_path])
ds._tracked_events = []
# Set event_classes directly to avoid needing invoke_startup
ds.event_classes = (DummyEvent,)
async def recording_track_event(event):
ds._tracked_events.append(event)
ds.track_event = recording_track_event
yield ds
@pytest.mark.asyncio
async def test_track_event_in_write_fn(ds_with_event_tracking):
"""fn(conn, track_event) can queue events that are dispatched after commit."""
ds = ds_with_event_tracking
db = ds.get_database("test")
def my_write(conn, track_event):
conn.execute("create table if not exists te1 (id integer primary key)")
track_event(DummyEvent(actor=None, message="hello"))
await db.execute_write_fn(my_write)
assert len(ds._tracked_events) == 1
assert ds._tracked_events[0].message == "hello"
@pytest.mark.asyncio
async def test_track_event_discarded_on_exception(ds_with_event_tracking):
"""Events are discarded if the write fn raises an exception."""
ds = ds_with_event_tracking
db = ds.get_database("test")
def my_write(conn, track_event):
track_event(DummyEvent(actor=None, message="should not fire"))
raise ValueError("deliberate error")
with pytest.raises(ValueError, match="deliberate"):
await db.execute_write_fn(my_write)
assert len(ds._tracked_events) == 0
@pytest.mark.asyncio
async def test_track_event_existing_fn_signature_still_works(ds_with_event_tracking):
"""Existing fn(conn) signatures continue to work without track_event."""
ds = ds_with_event_tracking
db = ds.get_database("test")
await db.execute_write_fn(
lambda conn: conn.execute(
"create table if not exists te2 (id integer primary key)"
)
)
# No events, no errors
assert len(ds._tracked_events) == 0
@pytest.mark.asyncio
async def test_track_event_in_write_wrapper(ds_with_event_tracking):
"""write_wrapper generator with (conn, track_event) can queue events."""
ds = ds_with_event_tracking
db = ds.get_database("test")
class Plugin:
__name__ = "Plugin"
@staticmethod
@hookimpl
def write_wrapper(datasette, database, request, transaction):
def wrapper(conn, track_event):
track_event(DummyEvent(actor=None, message="from wrapper before"))
yield
track_event(DummyEvent(actor=None, message="from wrapper after"))
return wrapper
pm.register(Plugin(), name="test_track_wrapper")
try:
await db.execute_write_fn(
lambda conn: conn.execute(
"create table if not exists te3 (id integer primary key)"
)
)
assert len(ds._tracked_events) == 2
assert ds._tracked_events[0].message == "from wrapper before"
assert ds._tracked_events[1].message == "from wrapper after"
finally:
pm.unregister(name="test_track_wrapper")
@pytest.mark.asyncio
async def test_track_event_shared_between_fn_and_wrapper(ds_with_event_tracking):
"""Both fn and wrapper can queue events, all dispatched in order."""
ds = ds_with_event_tracking
db = ds.get_database("test")
class Plugin:
__name__ = "Plugin"
@staticmethod
@hookimpl
def write_wrapper(datasette, database, request, transaction):
def wrapper(conn, track_event):
track_event(DummyEvent(actor=None, message="wrapper-before"))
yield
track_event(DummyEvent(actor=None, message="wrapper-after"))
return wrapper
pm.register(Plugin(), name="test_track_shared")
try:
def my_write(conn, track_event):
conn.execute("create table if not exists te4 (id integer primary key)")
track_event(DummyEvent(actor=None, message="from-fn"))
await db.execute_write_fn(my_write)
messages = [e.message for e in ds._tracked_events]
assert messages == ["wrapper-before", "from-fn", "wrapper-after"]
finally:
pm.unregister(name="test_track_shared")
@pytest.mark.asyncio
async def test_track_event_with_block_false(ds_with_event_tracking):
"""Events are dispatched even when block=False (non-blocking writes)."""
ds = ds_with_event_tracking
db = ds.get_database("test")
def my_write(conn, track_event):
conn.execute("create table if not exists te5 (id integer primary key)")
track_event(DummyEvent(actor=None, message="non-blocking"))
task_id = await db.execute_write_fn(my_write, block=False)
assert task_id is not None
# Give the background task time to complete
import asyncio
for _ in range(50):
if ds._tracked_events:
break
await asyncio.sleep(0.01)
assert len(ds._tracked_events) == 1
assert ds._tracked_events[0].message == "non-blocking"
# --- Tests for RenameTableEvent detection ---
@pytest.fixture
def ds_for_rename(tmp_path):
"""Datasette instance that records tracked events for rename detection tests."""
from datasette.events import RenameTableEvent
db_path = str(tmp_path / "test.db")
ds = Datasette([db_path])
ds._tracked_events = []
ds.event_classes = (RenameTableEvent,)
async def recording_track_event(event):
ds._tracked_events.append(event)
ds.track_event = recording_track_event
return ds
@pytest.mark.asyncio
async def test_rename_table_fires_event(ds_for_rename):
"""Renaming a table via ALTER TABLE fires a RenameTableEvent."""
from datasette.events import RenameTableEvent
ds = ds_for_rename
db = ds.get_database("test")
await db.execute_write("create table old_name (id integer primary key)")
def rename(conn):
conn.execute("alter table old_name rename to new_name")
await db.execute_write_fn(rename)
rename_events = [e for e in ds._tracked_events if isinstance(e, RenameTableEvent)]
assert len(rename_events) == 1
assert rename_events[0].old_table == "old_name"
assert rename_events[0].new_table == "new_name"
assert rename_events[0].database == "test"
@pytest.mark.asyncio
async def test_no_rename_event_for_regular_writes(ds_for_rename):
"""Regular writes (CREATE, INSERT) do not fire RenameTableEvent."""
from datasette.events import RenameTableEvent
ds = ds_for_rename
db = ds.get_database("test")
await db.execute_write("create table t (id integer primary key)")
await db.execute_write_fn(lambda conn: conn.execute("insert into t values (1)"))
rename_events = [e for e in ds._tracked_events if isinstance(e, RenameTableEvent)]
assert len(rename_events) == 0
@pytest.mark.asyncio
async def test_no_rename_event_on_rollback(ds_for_rename):
"""RenameTableEvent is not fired if the write raises an exception."""
from datasette.events import RenameTableEvent
ds = ds_for_rename
db = ds.get_database("test")
await db.execute_write("create table rollback_test (id integer primary key)")
def rename_then_fail(conn):
conn.execute("alter table rollback_test rename to renamed")
raise ValueError("deliberate error")
with pytest.raises(ValueError, match="deliberate"):
await db.execute_write_fn(rename_then_fail)
rename_events = [e for e in ds._tracked_events if isinstance(e, RenameTableEvent)]
assert len(rename_events) == 0
@pytest.mark.asyncio
async def test_multiple_renames_in_one_write(ds_for_rename):
"""Multiple renames in a single write fire multiple RenameTableEvents."""
from datasette.events import RenameTableEvent
ds = ds_for_rename
db = ds.get_database("test")
await db.execute_write("create table alpha (id integer primary key)")
await db.execute_write("create table beta (id integer primary key)")
def rename_both(conn):
conn.execute("alter table alpha rename to alpha2")
conn.execute("alter table beta rename to beta2")
await db.execute_write_fn(rename_both)
rename_events = [e for e in ds._tracked_events if isinstance(e, RenameTableEvent)]
assert len(rename_events) == 2
names = {(e.old_table, e.new_table) for e in rename_events}
assert names == {("alpha", "alpha2"), ("beta", "beta2")}