Skip to content

Commit 3110faa

Browse files
authored
Replace Janus queue with asyncio.Future
Closes #1752 AI generated patch explanation: https://gisthost.github.io/?e2b8d9c7666e988b5c003ff5e5ef3098
1 parent 46d90a0 commit 3110faa

5 files changed

Lines changed: 140 additions & 47 deletions

File tree

datasette/database.py

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import inspect
55
import os
66
from pathlib import Path
7-
import janus
87
import queue
98
import sqlite_utils
109
import sys
@@ -330,13 +329,16 @@ def track_event(event):
330329
else:
331330
# For non-blocking writes, spawn a background task to
332331
# dispatch events after the write thread completes
333-
task_id, reply_queue = result
332+
task_id, reply_future = result
334333

335334
async def _dispatch_events_after_write():
336-
write_result = await reply_queue.async_q.get()
337-
if not isinstance(write_result, Exception):
338-
for event in pending_events:
339-
await self.ds.track_event(event)
335+
try:
336+
await reply_future
337+
except Exception:
338+
# if the write failed, don't emit success events
339+
return
340+
for event in pending_events:
341+
await self.ds.track_event(event)
340342

341343
asyncio.ensure_future(_dispatch_events_after_write())
342344
result = task_id
@@ -390,18 +392,15 @@ async def _send_to_write_thread(
390392
)
391393
self._write_thread.start()
392394
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
393-
reply_queue = janus.Queue()
395+
loop = asyncio.get_running_loop()
396+
reply_future = loop.create_future()
394397
self._write_queue.put(
395-
WriteTask(fn, task_id, reply_queue, isolated_connection, transaction)
398+
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
396399
)
397400
if block:
398-
result = await reply_queue.async_q.get()
399-
if isinstance(result, Exception):
400-
raise result
401-
else:
402-
return result
401+
return await reply_future
403402
else:
404-
return task_id, reply_queue
403+
return task_id, reply_future
405404

406405
def _execute_writes(self):
407406
# Infinite looping thread that protects the single write connection
@@ -422,36 +421,37 @@ def _execute_writes(self):
422421
except Exception:
423422
pass
424423
return
424+
exception = None
425+
result = None
425426
if conn_exception is not None:
426-
result = conn_exception
427-
else:
428-
if task.isolated_connection:
429-
isolated_connection = self.connect(write=True)
430-
try:
431-
result = task.fn(isolated_connection)
432-
except Exception as e:
433-
sys.stderr.write("{}\n".format(e))
434-
sys.stderr.flush()
435-
result = e
436-
finally:
437-
isolated_connection.close()
438-
try:
439-
self._all_file_connections.remove(isolated_connection)
440-
except ValueError:
441-
# Was probably a memory connection
442-
pass
443-
else:
427+
exception = conn_exception
428+
elif task.isolated_connection:
429+
isolated_connection = self.connect(write=True)
430+
try:
431+
result = task.fn(isolated_connection)
432+
except Exception as e:
433+
sys.stderr.write("{}\n".format(e))
434+
sys.stderr.flush()
435+
exception = e
436+
finally:
437+
isolated_connection.close()
444438
try:
445-
if task.transaction:
446-
with conn:
447-
result = task.fn(conn)
448-
else:
439+
self._all_file_connections.remove(isolated_connection)
440+
except ValueError:
441+
# Was probably a memory connection
442+
pass
443+
else:
444+
try:
445+
if task.transaction:
446+
with conn:
449447
result = task.fn(conn)
450-
except Exception as e:
451-
sys.stderr.write("{}\n".format(e))
452-
sys.stderr.flush()
453-
result = e
454-
task.reply_queue.sync_q.put(result)
448+
else:
449+
result = task.fn(conn)
450+
except Exception as e:
451+
sys.stderr.write("{}\n".format(e))
452+
sys.stderr.flush()
453+
exception = e
454+
_deliver_write_result(task, result, exception)
455455

456456
async def execute_fn(self, fn):
457457
self._check_not_closed()
@@ -892,16 +892,45 @@ def wrapped(conn):
892892

893893

894894
class WriteTask:
895-
__slots__ = ("fn", "task_id", "reply_queue", "isolated_connection", "transaction")
895+
__slots__ = (
896+
"fn",
897+
"task_id",
898+
"loop",
899+
"reply_future",
900+
"isolated_connection",
901+
"transaction",
902+
)
896903

897-
def __init__(self, fn, task_id, reply_queue, isolated_connection, transaction):
904+
def __init__(
905+
self, fn, task_id, loop, reply_future, isolated_connection, transaction
906+
):
898907
self.fn = fn
899908
self.task_id = task_id
900-
self.reply_queue = reply_queue
909+
self.loop = loop
910+
self.reply_future = reply_future
901911
self.isolated_connection = isolated_connection
902912
self.transaction = transaction
903913

904914

915+
def _deliver_write_result(task, result, exception):
916+
# Called from the write thread. Delivers the result back to the
917+
# awaiting coroutine on its event loop via call_soon_threadsafe.
918+
def _set():
919+
if task.reply_future.done():
920+
# Awaiter was cancelled; nothing to do.
921+
return
922+
if exception is not None:
923+
task.reply_future.set_exception(exception)
924+
else:
925+
task.reply_future.set_result(result)
926+
927+
try:
928+
task.loop.call_soon_threadsafe(_set)
929+
except RuntimeError:
930+
# Event loop has been closed; the awaiter is gone.
931+
pass
932+
933+
905934
class QueryInterrupted(Exception):
906935
def __init__(self, e, sql, params):
907936
self.e = e

docs/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
Changelog
55
=========
66

7+
.. _unreleased:
8+
9+
Unreleased
10+
----------
11+
12+
- 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`)
13+
14+
715
.. _v1_0_a29:
816

917
1.0a29 (2026-05-12)

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ dependencies = [
3232
"pluggy>=1.0",
3333
"uvicorn>=0.11",
3434
"aiofiles>=0.4",
35-
"janus>=0.6.2",
3635
"PyYAML>=5.3",
3736
"mergedeep>=1.1.1",
3837
"itsdangerous>=1.1",

tests/test_internals_database.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
Tests for the datasette.database.Database class
33
"""
44

5+
import asyncio
6+
from types import SimpleNamespace
57
from datasette.app import Datasette
68
from datasette.database import Database, Results, MultipleValues
79
from datasette.database import DatasetteClosedError
10+
from datasette.database import _deliver_write_result
811
from datasette.utils.sqlite import sqlite3, sqlite_version
912
from datasette.utils import Column
1013
import pytest
@@ -590,6 +593,37 @@ def write_fn(conn):
590593
app_client.ds.remove_database("immutable-db")
591594

592595

596+
@pytest.mark.asyncio
597+
async def test_deliver_write_result_leaves_done_future_alone():
598+
loop = asyncio.get_running_loop()
599+
reply_future = loop.create_future()
600+
reply_future.set_result("original")
601+
task = SimpleNamespace(loop=loop, reply_future=reply_future)
602+
603+
# The write thread can finish after the caller has stopped waiting for the
604+
# result. Delivery should notice that the future is already resolved and
605+
# leave the caller's outcome alone instead of raising InvalidStateError.
606+
_deliver_write_result(task, "replacement", None)
607+
await asyncio.sleep(0)
608+
609+
assert reply_future.result() == "original"
610+
611+
612+
@pytest.mark.asyncio
613+
async def test_deliver_write_result_ignores_closed_loop():
614+
closed_loop = asyncio.new_event_loop()
615+
closed_loop.close()
616+
reply_future = asyncio.get_running_loop().create_future()
617+
task = SimpleNamespace(loop=closed_loop, reply_future=reply_future)
618+
619+
# If the event loop that submitted the write has gone away, the write
620+
# thread should drop the result rather than crash while reporting back to
621+
# that closed loop.
622+
_deliver_write_result(task, "result", None)
623+
624+
assert not reply_future.done()
625+
626+
593627
def table_exists(conn, name):
594628
return bool(
595629
conn.execute(

tests/test_write_wrapper.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for the write_wrapper plugin hook.
33
"""
44

5+
import asyncio
56
from dataclasses import dataclass
67
from datasette.app import Datasette
78
from datasette.events import Event
@@ -633,8 +634,6 @@ def my_write(conn, track_event):
633634
assert task_id is not None
634635

635636
# Give the background task time to complete
636-
import asyncio
637-
638637
for _ in range(50):
639638
if ds._tracked_events:
640639
break
@@ -644,6 +643,30 @@ def my_write(conn, track_event):
644643
assert ds._tracked_events[0].message == "non-blocking"
645644

646645

646+
@pytest.mark.asyncio
647+
async def test_track_event_with_block_false_discarded_on_exception(
648+
ds_with_event_tracking,
649+
):
650+
"""Events queued by a non-blocking write are discarded if the write fails."""
651+
ds = ds_with_event_tracking
652+
db = ds.get_database("test")
653+
654+
def my_write(conn, track_event):
655+
track_event(DummyEvent(actor=None, message="should not fire"))
656+
raise ValueError("deliberate error")
657+
658+
task_id = await db.execute_write_fn(my_write, block=False)
659+
assert task_id is not None
660+
661+
# A following blocking write proves the failed non-blocking task has
662+
# completed; one more loop turn lets its event-dispatch task observe the
663+
# exception and exit.
664+
await db.execute_write_fn(lambda conn: conn.execute("select 1"))
665+
await asyncio.sleep(0)
666+
667+
assert ds._tracked_events == []
668+
669+
647670
# --- Tests for RenameTableEvent detection ---
648671

649672

0 commit comments

Comments
 (0)