mirror of
https://github.com/simonw/datasette.git
synced 2026-06-06 00:56:57 +02:00
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:
parent
9b5cb1347c
commit
312f41b0c2
8 changed files with 423 additions and 24 deletions
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue