Replace Janus queue with asyncio.Future

Closes #1752

AI generated patch explanation: https://gisthost.github.io/?e2b8d9c7666e988b5c003ff5e5ef3098
This commit is contained in:
Simon Willison 2026-05-16 11:45:43 -07:00 committed by GitHub
commit 3110faa0ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 140 additions and 47 deletions

View file

@ -4,7 +4,6 @@ from collections import namedtuple
import inspect import inspect
import os import os
from pathlib import Path from pathlib import Path
import janus
import queue import queue
import sqlite_utils import sqlite_utils
import sys import sys
@ -330,13 +329,16 @@ class Database:
else: else:
# For non-blocking writes, spawn a background task to # For non-blocking writes, spawn a background task to
# dispatch events after the write thread completes # dispatch events after the write thread completes
task_id, reply_queue = result task_id, reply_future = result
async def _dispatch_events_after_write(): async def _dispatch_events_after_write():
write_result = await reply_queue.async_q.get() try:
if not isinstance(write_result, Exception): await reply_future
for event in pending_events: except Exception:
await self.ds.track_event(event) # if the write failed, don't emit success events
return
for event in pending_events:
await self.ds.track_event(event)
asyncio.ensure_future(_dispatch_events_after_write()) asyncio.ensure_future(_dispatch_events_after_write())
result = task_id result = task_id
@ -390,18 +392,15 @@ class Database:
) )
self._write_thread.start() self._write_thread.start()
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io") task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
reply_queue = janus.Queue() loop = asyncio.get_running_loop()
reply_future = loop.create_future()
self._write_queue.put( self._write_queue.put(
WriteTask(fn, task_id, reply_queue, isolated_connection, transaction) WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
) )
if block: if block:
result = await reply_queue.async_q.get() return await reply_future
if isinstance(result, Exception):
raise result
else:
return result
else: else:
return task_id, reply_queue return task_id, reply_future
def _execute_writes(self): def _execute_writes(self):
# Infinite looping thread that protects the single write connection # Infinite looping thread that protects the single write connection
@ -422,36 +421,37 @@ class Database:
except Exception: except Exception:
pass pass
return return
exception = None
result = None
if conn_exception is not None: if conn_exception is not None:
result = conn_exception exception = conn_exception
elif task.isolated_connection:
isolated_connection = self.connect(write=True)
try:
result = task.fn(isolated_connection)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
exception = e
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
else: else:
if task.isolated_connection: try:
isolated_connection = self.connect(write=True) if task.transaction:
try: with conn:
result = task.fn(isolated_connection)
except Exception as e:
sys.stderr.write("{}\n".format(e))
sys.stderr.flush()
result = e
finally:
isolated_connection.close()
try:
self._all_file_connections.remove(isolated_connection)
except ValueError:
# Was probably a memory connection
pass
else:
try:
if task.transaction:
with conn:
result = task.fn(conn)
else:
result = task.fn(conn) result = task.fn(conn)
except Exception as e: else:
sys.stderr.write("{}\n".format(e)) result = task.fn(conn)
sys.stderr.flush() except Exception as e:
result = e sys.stderr.write("{}\n".format(e))
task.reply_queue.sync_q.put(result) sys.stderr.flush()
exception = e
_deliver_write_result(task, result, exception)
async def execute_fn(self, fn): async def execute_fn(self, fn):
self._check_not_closed() self._check_not_closed()
@ -892,16 +892,45 @@ def _apply_write_wrapper(fn, wrapper_factory, track_event):
class WriteTask: class WriteTask:
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction") __slots__ = (
"fn",
"task_id",
"loop",
"reply_future",
"isolated_connection",
"transaction",
)
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction): def __init__(
self, fn, task_id, loop, reply_future, isolated_connection, transaction
):
self.fn = fn self.fn = fn
self.task_id = task_id self.task_id = task_id
self.reply_queue = reply_queue self.loop = loop
self.reply_future = reply_future
self.isolated_connection = isolated_connection self.isolated_connection = isolated_connection
self.transaction = transaction self.transaction = transaction
def _deliver_write_result(task, result, exception):
# Called from the write thread. Delivers the result back to the
# awaiting coroutine on its event loop via call_soon_threadsafe.
def _set():
if task.reply_future.done():
# Awaiter was cancelled; nothing to do.
return
if exception is not None:
task.reply_future.set_exception(exception)
else:
task.reply_future.set_result(result)
try:
task.loop.call_soon_threadsafe(_set)
except RuntimeError:
# Event loop has been closed; the awaiter is gone.
pass
class QueryInterrupted(Exception): class QueryInterrupted(Exception):
def __init__(self, e, sql, params): def __init__(self, e, sql, params):
self.e = e self.e = e

View file

@ -4,6 +4,14 @@
Changelog Changelog
========= =========
.. _unreleased:
Unreleased
----------
- Dropped Janus as a dependency, previously used to manage the write queue. This should not have any impact on plugin developers or end-users. (:issue:`1752`)
.. _v1_0_a29: .. _v1_0_a29:
1.0a29 (2026-05-12) 1.0a29 (2026-05-12)

View file

@ -32,7 +32,6 @@ dependencies = [
"pluggy>=1.0", "pluggy>=1.0",
"uvicorn>=0.11", "uvicorn>=0.11",
"aiofiles>=0.4", "aiofiles>=0.4",
"janus>=0.6.2",
"PyYAML>=5.3", "PyYAML>=5.3",
"mergedeep>=1.1.1", "mergedeep>=1.1.1",
"itsdangerous>=1.1", "itsdangerous>=1.1",

View file

@ -2,9 +2,12 @@
Tests for the datasette.database.Database class Tests for the datasette.database.Database class
""" """
import asyncio
from types import SimpleNamespace
from datasette.app import Datasette from datasette.app import Datasette
from datasette.database import Database, Results, MultipleValues from datasette.database import Database, Results, MultipleValues
from datasette.database import DatasetteClosedError from datasette.database import DatasetteClosedError
from datasette.database import _deliver_write_result
from datasette.utils.sqlite import sqlite3, sqlite_version from datasette.utils.sqlite import sqlite3, sqlite_version
from datasette.utils import Column from datasette.utils import Column
import pytest import pytest
@ -590,6 +593,37 @@ async def test_execute_write_fn_connection_exception(tmpdir, app_client):
app_client.ds.remove_database("immutable-db") app_client.ds.remove_database("immutable-db")
@pytest.mark.asyncio
async def test_deliver_write_result_leaves_done_future_alone():
loop = asyncio.get_running_loop()
reply_future = loop.create_future()
reply_future.set_result("original")
task = SimpleNamespace(loop=loop, reply_future=reply_future)
# The write thread can finish after the caller has stopped waiting for the
# result. Delivery should notice that the future is already resolved and
# leave the caller's outcome alone instead of raising InvalidStateError.
_deliver_write_result(task, "replacement", None)
await asyncio.sleep(0)
assert reply_future.result() == "original"
@pytest.mark.asyncio
async def test_deliver_write_result_ignores_closed_loop():
closed_loop = asyncio.new_event_loop()
closed_loop.close()
reply_future = asyncio.get_running_loop().create_future()
task = SimpleNamespace(loop=closed_loop, reply_future=reply_future)
# If the event loop that submitted the write has gone away, the write
# thread should drop the result rather than crash while reporting back to
# that closed loop.
_deliver_write_result(task, "result", None)
assert not reply_future.done()
def table_exists(conn, name): def table_exists(conn, name):
return bool( return bool(
conn.execute( conn.execute(

View file

@ -2,6 +2,7 @@
Tests for the write_wrapper plugin hook. Tests for the write_wrapper plugin hook.
""" """
import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from datasette.app import Datasette from datasette.app import Datasette
from datasette.events import Event from datasette.events import Event
@ -633,8 +634,6 @@ async def test_track_event_with_block_false(ds_with_event_tracking):
assert task_id is not None assert task_id is not None
# Give the background task time to complete # Give the background task time to complete
import asyncio
for _ in range(50): for _ in range(50):
if ds._tracked_events: if ds._tracked_events:
break break
@ -644,6 +643,30 @@ async def test_track_event_with_block_false(ds_with_event_tracking):
assert ds._tracked_events[0].message == "non-blocking" assert ds._tracked_events[0].message == "non-blocking"
@pytest.mark.asyncio
async def test_track_event_with_block_false_discarded_on_exception(
ds_with_event_tracking,
):
"""Events queued by a non-blocking write are discarded if the write fails."""
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")
task_id = await db.execute_write_fn(my_write, block=False)
assert task_id is not None
# A following blocking write proves the failed non-blocking task has
# completed; one more loop turn lets its event-dispatch task observe the
# exception and exit.
await db.execute_write_fn(lambda conn: conn.execute("select 1"))
await asyncio.sleep(0)
assert ds._tracked_events == []
# --- Tests for RenameTableEvent detection --- # --- Tests for RenameTableEvent detection ---