Torture harness, and the fixes it found

tests/connection_torture.py drives one Datasette from several event loops
(threads) with the smallest pool, 1ms idle timeout and 10ms schema polling
through every connection operation, plus add/remove_database, scratch
databases, external schema changes, replaced/deleted files, cancellation,
Database.close()/Datasette.close() mid-flight and non-threaded mode. One
scenario per process, so crashes and hangs are results. It checks for hangs,
crashes, undocumented exceptions, per-database write order, lost writes,
read-your-writes, catalog freshness, and fds/threads/the instance released
after close(). tests/test_connection_torture.py runs a short fixed-seed
subset of every scenario.

Fixes for what it found:
- A cursor that left the thread owning its connection (the cursor returned
  by execute_write_many()/execute_write_script(), one returned by a write
  callback, one rejected by execute_fn(), or one held by an exception's
  frames) could be freed on the event loop thread while the connection ran
  the same SQL again; on Python < 3.12 freeing it resets the shared cached
  statement, and the other query failed with "bad parameter or other API
  misuse". Such cursors are now closed on the owning thread. Pre-existing on
  main (51 failures per 1,800 multi-loop execute_write_many calls).
- invoke_startup() is single-flight across event loops (a concurrent.futures
  future, datasette.utils.inflight) instead of _startup_sequence() holding
  an asyncio.Lock: a second loop got "bound to a different event loop" or
  hung, and two loops could run the startup hooks twice. A request made
  from a startup hook no longer re-runs or deadlocks startup.
- The index page takes one snapshot of ds.databases: a database removed,
  renamed or closed mid-request, or whose file was deleted, no longer turns
  the page into a 500. Database.size is 0 for a deleted file.

The scan-coalescing helpers move to datasette/utils/inflight.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv
This commit is contained in:
Claude 2026-10-04 09:07:48 +00:00
commit da523c30e5
No known key found for this signature in database
11 changed files with 1763 additions and 106 deletions

View file

@ -116,6 +116,7 @@ from .utils.asgi import (
asgi_static,
)
from .utils.catalog import all_derived_table_dependencies
from .utils.inflight import InFlight, wait_for_concurrent
from .utils.internal_db import init_internal_db
from .utils.sqlite import (
sqlite3,
@ -499,17 +500,11 @@ class Datasette:
# Datasette can be driven by several event loops, and an
# asyncio.Lock binds to whichever loop first waits on it
self._refresh_schemas_lock = threading.Lock()
try:
self._startup_lock = asyncio.Lock()
except RuntimeError as rex:
# Workaround for intermittent test failure, see:
# https://github.com/simonw/datasette/issues/1802
if "There is no current event loop in thread" in str(rex):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._startup_lock = asyncio.Lock()
else:
raise
# invoke_startup() in progress (datasette.utils.inflight.InFlight),
# claimed under this lock: concurrent callers on any event loop wait
# for it. Not an asyncio.Lock, which binds to one event loop
self._startup_running = None
self._startup_state_lock = threading.Lock()
self._background_tasks = BackgroundTaskSupervisor(self)
self.crossdb = crossdb
self.nolock = nolock
@ -834,9 +829,40 @@ class Datasette:
return pm
async def invoke_startup(self):
# This must be called for Datasette to be in a usable state
if self._startup_invoked:
# This must be called for Datasette to be in a usable state.
# Several event loops (threads) may call it at once: one runs
# startup, the others wait for it to finish - and try again
# themselves if it failed or its event loop went away.
while not self._startup_invoked:
loop = asyncio.get_running_loop()
task = asyncio.current_task()
with self._startup_state_lock:
running = self._startup_running
if running is not None and (running.done() or running.abandoned()):
running.finish()
running = self._startup_running = None
if running is None:
running = self._startup_running = InFlight(loop, task)
mine = True
elif running.task is task:
# Called from inside startup itself (a startup hook
# making a request through datasette.client)
return
else:
mine = False
if not mine:
await wait_for_concurrent(running.future)
continue
try:
await self._invoke_startup()
finally:
with self._startup_state_lock:
if self._startup_running is running:
self._startup_running = None
running.finish()
return
async def _invoke_startup(self):
# Group spans created during startup under a single parent span
with tracer.start_as_current_span(STARTUP):
# Register event classes
@ -3293,16 +3319,13 @@ ORDER BY allowed.parent, allowed.child
"""
if self._startup_invoked and self._setup_db_done:
return
async with self._startup_lock:
if self._startup_invoked and self._setup_db_done:
return
if not self._setup_db_done:
# Immutable databases used to have their table counts
# computed here, opening every one of them at startup. They
# are now computed the first time a page needs them (see
# Database.table_counts()) and cached from then on.
self._setup_db_done = True
await self.invoke_startup()
# Immutable databases used to have their table counts computed
# here, opening every one of them at startup. They are now computed
# the first time a page needs them (see Database.table_counts()) and
# cached from then on. invoke_startup() makes concurrent callers -
# on any event loop - wait for the one running it.
self._setup_db_done = True
await self.invoke_startup()
def add_background_task(self, func, name=None) -> BackgroundTask:
"""Register a piece of supervised background work, typically from

View file

@ -154,6 +154,58 @@ class LeasedConnection:
return f"<LeasedConnection {self._kind} database={self._db_name!r} {state}>"
def close_cursors(value):
"""Close the cursors in a callback's result (a cursor, or a tuple or
list holding one), in a generator's locals, or - for an exception - in
the locals of the frames it was raised through.
Call it on the thread that owns the connection, before the connection
can be used by anyone else. A cursor keeps a reference to the
connection's cached statement for its SQL; on Python before 3.12,
deallocating the cursor resets that statement. If the cursor dies on
another thread (the caller's event loop) while the connection is running
the same SQL again - the write thread's next execute_write_many(), or
another worker that has leased the connection - the statement is reset
mid-step and that query fails with "bad parameter or other API misuse".
A closed cursor keeps its rowcount, lastrowid and description.
"""
if isinstance(value, BaseException):
tb = value.__traceback__
while tb is not None:
try:
values = list(tb.tb_frame.f_locals.values())
except Exception: # noqa: BLE001
values = []
for item in values:
if isinstance(item, sqlite3.Cursor):
_close_cursor(item)
tb = tb.tb_next
return
if isinstance(value, sqlite3.Cursor):
_close_cursor(value)
elif isinstance(value, (tuple, list)):
for item in value:
if isinstance(item, sqlite3.Cursor):
_close_cursor(item)
elif inspect.isgenerator(value):
try:
values = list(inspect.getgeneratorlocals(value).values())
except Exception: # noqa: BLE001
values = []
for item in values:
if isinstance(item, sqlite3.Cursor):
_close_cursor(item)
value.close()
def _close_cursor(cursor):
try:
cursor.close()
except Exception: # noqa: BLE001, S110
# Its connection is closed already
pass
def _generator_uses_connection(result):
"""True for a generator that would step the leased connection after
the callback returned: one holding the connection (a closure over
@ -219,12 +271,19 @@ class ReadConnectionPool:
"""Call fn(conn) with a pooled connection leased for the duration."""
entry = self.acquire(db)
lease = LeasedConnection(entry.conn, db.name, "read")
rejected = False
try:
result = fn(lease)
if isinstance(result, sqlite3.Cursor) or _generator_uses_connection(result):
rejected = True
close_cursors(result)
except BaseException as e:
close_cursors(e)
raise
finally:
lease._expire()
self.release(entry)
if isinstance(result, sqlite3.Cursor) or _generator_uses_connection(result):
if rejected:
raise ConnectionLeaseError(
f"An execute_fn() callback for database {db.name!r} returned a {type(result).__name__}, which "
"would keep using the pooled connection after the callback "

View file

@ -16,7 +16,11 @@ import sqlite_utils
from opentelemetry import context as otel_context_api
from opentelemetry.trace import Status, StatusCode
from .connection_pool import ConnectionLeaseError, LeasedConnection # noqa: F401
from .connection_pool import ( # noqa: F401
ConnectionLeaseError,
LeasedConnection,
close_cursors,
)
from .inspect import inspect_hash
from .telemetry import (
callback_name,
@ -1119,7 +1123,12 @@ class Database:
elif self.is_memory:
return 0
elif self.is_mutable:
return Path(self.path).stat().st_size
try:
return Path(self.path).stat().st_size
except FileNotFoundError:
# Deleted from under Datasette: report it as empty rather
# than failing every page that lists databases
return 0
elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):
self.cached_size = self.ds.inspect_data[self.name]["size"]
return self.cached_size
@ -1440,12 +1449,22 @@ def _call_with_lease(fn, conn, db_name, kind):
Unlike read callbacks, write callbacks may return a cursor (for example
``lambda conn: conn.execute(...)``, and execute_write_many() and
execute_write_script() return theirs); reading attributes such as
``lastrowid`` from it afterwards is fine, fetching rows is not.
execute_write_script() return theirs); it is closed before it leaves
this thread, so reading attributes such as ``lastrowid`` from it
afterwards is fine, fetching rows is not.
"""
lease = LeasedConnection(conn, db_name, kind)
try:
return fn(lease)
result = fn(lease)
except BaseException as e:
close_cursors(e)
raise
else:
# A returned cursor is closed here, on the thread that owns the
# connection (see close_cursors()); its rowcount and lastrowid can
# still be read
close_cursors(result)
return result
finally:
lease._expire()

View file

@ -74,7 +74,6 @@ open.
"""
import asyncio
import concurrent.futures
import json
import logging
import os
@ -83,6 +82,7 @@ import time
from .utils import sqlite3
from .utils.catalog import remember_derived_table_dependencies
from .utils.inflight import InFlight, wait_for_concurrent
from .utils.internal_db import CATALOG_TABLES, collect_schema, write_catalog_entries
logger = logging.getLogger("datasette.schema_watcher")
@ -144,7 +144,8 @@ class WatchState:
self.catalog_version = None
self.notified_version = None
self.needs_scan = True
# An _InflightScan while a catalog scan of this database runs
# An InFlight (datasette.utils.inflight) while a catalog scan of
# this database runs
self.scan_future = None
# time.monotonic() before which a failed scan is not retried, and
# the delay used for the next failure (doubles, see RETRY_DELAY_S)
@ -248,61 +249,6 @@ def header_schema_version(path):
return int.from_bytes(header[40:44], "big")
class _InflightScan:
"""One catalog scan in progress for one database.
Not bound to an event loop: one Datasette is driven by several loops
(``--get``, TestClient, pytest-asyncio), each in its own thread, and a
write on any of them may need to wait for a scan started on another.
Waiters use :func:`_wait_for` on ``future``, a ``concurrent.futures``
future that the scan's owner resolves in a ``finally`` block.
"""
__slots__ = ("future", "loop")
def __init__(self, loop):
self.future = concurrent.futures.Future()
self.loop = loop
def done(self):
return self.future.done()
def abandoned(self):
"""True if nothing will ever finish this scan: the loop running it
has closed, or has stopped (``run_until_complete()`` returned while
the scan task was still pending)."""
loop = self.loop
return loop.is_closed() or not loop.is_running()
def finish(self):
try:
self.future.set_result(None)
except concurrent.futures.InvalidStateError:
pass
def _wait_for(cf_future):
"""An asyncio future on the running loop that completes when the
concurrent future does. Cancelling it does not cancel cf_future, which
other waiters (maybe on other loops) share."""
loop = asyncio.get_running_loop()
waiter = loop.create_future()
def _set():
if not waiter.done():
waiter.set_result(None)
def _done(_):
try:
loop.call_soon_threadsafe(_set)
except RuntimeError:
# That loop has closed; nobody is waiting any more
pass
cf_future.add_done_callback(_done)
return waiter
def _is_transient(error):
if isinstance(error, sqlite3.OperationalError):
message = str(error)
@ -987,7 +933,7 @@ class SchemaWatcher:
inflight.finish()
if state.needs_scan and not state.removed and state not in failed:
state.needs_scan = False
state.scan_future = _InflightScan(loop)
state.scan_future = InFlight(loop)
mine.append(state)
if mine:
records = {state: state.scan_future for state in mine}
@ -1018,7 +964,7 @@ class SchemaWatcher:
for record in records.values():
record.finish()
if waits:
await asyncio.wait([_wait_for(f) for f in waits])
await asyncio.wait([wait_for_concurrent(f) for f in waits])
pending = [
s
for s in pending
@ -1042,7 +988,7 @@ class SchemaWatcher:
"""Wait for a catalog scan of state that is in flight, if any."""
inflight = state.scan_future
if inflight is not None and not inflight.done() and not inflight.abandoned():
await _wait_for(inflight.future)
await wait_for_concurrent(inflight.future)
async def _scan_and_store(self, states, records, handled):
memory = [s for s in states if not s.is_file and s.db.memory_name]
@ -1069,7 +1015,7 @@ class SchemaWatcher:
async def _store(self, results, records=None, handled=None):
"""Write scan results to the catalog. ``records`` maps each state to
the _InflightScan this scan was started as: results for a state whose
the InFlight this scan was started as: results for a state whose
scan has since been taken over by another loop are dropped. States
whose outcome was recorded are added to ``handled``; the others
(transient failures) are retried later by refresh()."""

View file

@ -0,0 +1,63 @@
"""Waiting for work started on another event loop.
One Datasette can be driven by several event loops at once, each in its own
thread (``datasette --get``, TestClient, pytest-asyncio, an embedding app).
asyncio primitives (``asyncio.Lock``, ``asyncio.Future``) belong to one loop,
so "one call does the work, concurrent callers wait for it" is built from a
``concurrent.futures.Future`` that any loop can wait for, and a
``threading.Lock`` held only for the moment it takes to claim the work.
"""
import asyncio
import concurrent.futures
class InFlight:
"""One piece of work in progress, started by ``task`` on ``loop``."""
__slots__ = ("future", "loop", "task")
def __init__(self, loop, task=None):
self.future = concurrent.futures.Future()
self.loop = loop
self.task = task
def done(self):
return self.future.done()
def abandoned(self):
"""True if nothing will ever finish this work: the loop running it
has closed, or has stopped (``run_until_complete()`` returned while
the task doing it was still pending)."""
loop = self.loop
return loop.is_closed() or not loop.is_running()
def finish(self):
"""Wake every waiter (they check for themselves whether the work
succeeded). Safe to call more than once, from any thread."""
try:
self.future.set_result(None)
except concurrent.futures.InvalidStateError:
pass
def wait_for_concurrent(cf_future):
"""An asyncio future on the running loop that completes when
``cf_future`` does. Cancelling it does not cancel ``cf_future``, which
other waiters - maybe on other loops - share."""
loop = asyncio.get_running_loop()
waiter = loop.create_future()
def _set():
if not waiter.done():
waiter.set_result(None)
def _done(_):
try:
loop.call_soon_threadsafe(_set)
except RuntimeError:
# That loop has closed; nobody is waiting any more
pass
cf_future.add_done_callback(_done)
return waiter

View file

@ -1,5 +1,6 @@
import json
from datasette.database import DatasetteClosedError
from datasette.plugins import pm
from datasette.utils import (
UNSTABLE_API_MESSAGE,
@ -7,6 +8,8 @@ from datasette.utils import (
add_cors_headers,
await_me_maybe,
make_slot_function,
sqlite3,
tilde_encode,
)
from datasette.utils.asgi import Response
from datasette.utils.catalog import (
@ -46,8 +49,13 @@ class IndexView(BaseView):
"view-database", request.actor, include_is_private=True
)
allowed_databases = [r async for r in db_page.all()]
# One snapshot for the whole request: add_database() and
# remove_database() replace ds.databases rather than changing it, so
# a database removed while this page is being built is still listed
# instead of failing the page with a KeyError
all_databases = self.ds.databases
allowed_db_dict = {
r.parent: r for r in allowed_databases if r.parent in self.ds.databases
r.parent: r for r in allowed_databases if r.parent in all_databases
}
# Group tables by database
@ -62,7 +70,7 @@ class IndexView(BaseView):
names = list(allowed_db_dict)
summaries = await catalog_summaries(self.ds, names)
all_counts = await self._table_counts(names)
all_counts = await self._table_counts(names, all_databases)
sort_by_relationships = request.args.get("_sort") == "relationships"
need_relationships = {
name for name in names if sort_by_relationships or not all_counts[name]
@ -149,7 +157,7 @@ class IndexView(BaseView):
table_counts,
tables_and_views_truncated,
) in prepared:
db = self.ds.databases[name]
db = all_databases[name]
tables_and_views_truncated = [
{
"name": t["name"],
@ -173,7 +181,8 @@ class IndexView(BaseView):
"name": name,
"hash": db.hash,
"color": db.color,
"path": self.ds.urls.database(name),
# From the snapshot: name may have been removed since
"path": self.ds.urls.path(tilde_encode(db.route)),
"tables_and_views_truncated": tables_and_views_truncated,
"tables_and_views_more": (len(visible_tables) + len(views))
> TRUNCATE_AT,
@ -189,6 +198,10 @@ class IndexView(BaseView):
}
)
# Leave out databases removed while this page was being built: the
# HTML template links to them by name
databases = [d for d in databases if d["name"] in self.ds.databases]
if as_format:
headers = {}
if self.ds.cors:
@ -235,13 +248,13 @@ class IndexView(BaseView):
},
)
async def _table_counts(self, names):
async def _table_counts(self, names, all_databases):
"""``{database: {table: count}}`` - empty for a database whose
counts are skipped or timed out (see COUNT_MAX_DATABASES)."""
counts = {}
to_count = []
for name in names:
db = self.ds.databases[name]
db = all_databases[name]
if not db.is_mutable and db.cached_table_counts is not None:
# Known without opening the database
counts[name] = db.cached_table_counts
@ -258,7 +271,12 @@ class IndexView(BaseView):
except OSError:
# The file has gone: there is nothing to count
continue
table_counts = await db.table_counts(10)
try:
table_counts = await db.table_counts(10)
except (DatasetteClosedError, sqlite3.Error):
# Closed or removed while this page was being built, or its
# file deleted or replaced: show it without counts
continue
# If any of these are None it means at least one timed out - ignore them all
if any(v is None for v in table_counts.values()):
table_counts = {}

View file

@ -1951,7 +1951,7 @@ Application lifecycle
Datasette guarantees a fixed sequence of events between the moment a ``Datasette`` instance is constructed and the moment its resources are released:
1. ``Datasette(...)`` — the constructor runs synchronously and does not run plugin hooks.
2. **Startup** — ``await datasette.invoke_startup()`` runs once: it populates the internal database's catalog of table schemas (:ref:`internals_internal`), loads canned queries and column type configuration, then calls every registered :ref:`plugin_hook_startup` hook, in plugin registration order. When Datasette is being served, table-count precomputation for immutable databases runs immediately before this, as part of the same startup sequence.
2. **Startup** — ``await datasette.invoke_startup()`` runs once, even if several event loops call it at the same time (the others wait for it to finish): it populates the internal database's catalog of table schemas (:ref:`internals_internal`), loads canned queries and column type configuration, then calls every registered :ref:`plugin_hook_startup` hook, in plugin registration order. When Datasette is being served, table-count precomputation for immutable databases runs immediately before this, as part of the same startup sequence.
3. **Background-task launch** — once *every* ``startup`` hook has finished (not before), every task registered with :ref:`datasette_add_background_task` — by any plugin — is launched. A task registered by one plugin's ``startup`` hook can safely depend on state set up by another plugin's ``startup`` hook, because launch only happens after the whole round of hooks completes.
4. **Serving** — the instance handles requests (or, for headless or CLI use, does whatever the embedding program does with it).
5. **Shutdown** — triggered by the ASGI ``lifespan.shutdown`` event (Ctrl-C, ``SIGTERM``) or the end of a ``datasette serve`` process: every :ref:`plugin_hook_shutdown` hook runs first, while background tasks are still alive, so a plugin can tell its own task to wind down gracefully; every still-running background task is then cancelled and given a five-second grace period to actually stop; finally every database connection is released via :ref:`datasette_close`.
@ -2239,7 +2239,7 @@ The write connection belongs to Datasette: calling ``conn.close()`` inside an ``
Datasette cannot detect every misuse. These are unsupported and may fail unpredictably, or read from a connection that is now being used by someone else:
- Iterating over or fetching from a **cursor** created inside the function after the function has returned. Write functions may return a cursor (``lambda conn: conn.execute(...)`` is a common pattern) and reading attributes such as ``.lastrowid`` and ``.rowcount`` from it afterwards is fine; fetching rows from it is not.
- Iterating over or fetching from a **cursor** created inside the function after the function has returned. Write functions may return a cursor (``lambda conn: conn.execute(...)`` is a common pattern) and reading attributes such as ``.lastrowid`` and ``.rowcount`` from it afterwards is fine; fetching rows from it is not. A cursor returned by a write or isolated function - including the ones returned by ``execute_write_many()`` and ``execute_write_script()`` - is closed before it is handed back, on the thread that owns the connection, and so are cursors left in the local variables of a function that raised an exception. (On Python versions before 3.12, a cursor that was freed on another thread could reset the same statement while the connection was running it again, making that query fail with "bad parameter or other API misuse".)
- Reaching the underlying connection through ``cursor.connection``.
- Relying on state set up on the connection by an earlier call (see above).

1297
tests/connection_torture.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -668,22 +668,22 @@ async def test_stale_owned_catalog_is_not_persisted_at_close(tmp_path):
make_db(path)
config = {"databases": {"data": {"schema_watch": "owned"}}}
async def tables_after_start(change=None):
async def tables_after_start(change_before_close=None):
ds = Datasette([path], internal=internal, config=config)
await ds.invoke_startup()
if change:
change()
response = await ds.client.get("/data.json")
if change_before_close:
# Behind Datasette's back, after the last request: an owned
# database is not polled, so the catalog is now stale
change_before_close()
ds.close()
return [t["name"] for t in response.json()["tables"]]
assert await tables_after_start() == ["t"]
# Owned: a change made behind Datasette's back is not noticed while
# it runs (documented) ...
assert await tables_after_start(
lambda: make_db(path, "create table t2 (id integer)")
) == ["t"]
# ... but it is not persisted as current either
# The stale catalog was not persisted as current: the restart rescans
assert await tables_after_start() == ["t", "t2"]
@ -744,3 +744,164 @@ async def test_execute_fn_may_return_generator_over_fetched_rows():
with pytest.raises(ConnectionLeaseError):
await db.execute_fn(closure)
ds.close()
# ----------------------------------------------------------------------
# Found by the torture harness (tests/connection_torture.py)
# ----------------------------------------------------------------------
@pytest.mark.asyncio
async def test_index_survives_database_removed_mid_request(tmp_path, monkeypatch):
# The index page looked databases up by name again after listing them:
# one removed meanwhile (remove_database(), a scratch delete or rename)
# failed the whole page with a KeyError
from datasette.views import index as index_module
paths = []
for name in ("keep", "gone"):
path = str(tmp_path / f"{name}.db")
make_db(path)
paths.append(path)
ds = Datasette(paths)
await ds.invoke_startup()
original = index_module.catalog_summaries
async def remove_during(datasette, names):
result = await original(datasette, names)
if "gone" in datasette.databases:
datasette.remove_database("gone")
return result
monkeypatch.setattr(index_module, "catalog_summaries", remove_during)
response = await ds.client.get("/.json")
assert response.status_code == 200
assert [d["name"] for d in response.json()["databases"]] == ["keep"]
ds.close()
@pytest.mark.asyncio
async def test_index_and_databases_with_closed_or_deleted_database(tmp_path):
paths = []
for name in ("keep", "closed", "deleted"):
path = str(tmp_path / f"{name}.db")
make_db(path)
paths.append(path)
ds = Datasette(paths, settings={"schema_watch_interval_ms": 0})
await ds.invoke_startup()
# Closed by a plugin but still attached
ds.get_database("closed").close()
# Deleted from under Datasette before any sweep noticed
os.unlink(paths[2])
for path in ("/.json", "/", "/-/databases.json"):
response = await ds.client.get(path)
assert response.status_code == 200, (path, response.text[:300])
databases = (await ds.client.get("/-/databases.json")).json()
if isinstance(databases, dict):
databases = databases["databases"]
sizes = {d["name"]: d["size"] for d in databases}
assert sizes["deleted"] == 0
ds.close()
@pytest.mark.asyncio
async def test_cursors_are_closed_before_leaving_their_thread(owned_db):
# A cursor still references its connection's cached statement, and on
# Python < 3.12 freeing it resets that statement. execute_write_many()
# handed its cursor to the caller's event loop thread; freed there while
# the write thread ran the same SQL again, the write failed with
# "bad parameter or other API misuse"
_ds, db, _path = owned_db
cursor = await db.execute_write_many(
"insert into t (v) values (?)", [("a",), ("b",)]
)
assert cursor.rowcount == 2
with pytest.raises(sqlite3.ProgrammingError):
cursor.fetchall()
cursor = await db.execute_write_fn(
lambda conn: conn.execute("insert into t (v) values ('c')")
)
assert cursor.lastrowid == 3
with pytest.raises(sqlite3.ProgrammingError):
cursor.fetchone()
# Cursors held by the frames of an exception raised by a callback are
# closed before the connection goes back to the pool (or write thread)
captured = []
def read_then_fail(conn):
cursor = conn.execute("select * from t")
captured.append(cursor)
raise ValueError("boom")
with pytest.raises(ValueError):
await db.execute_fn(read_then_fail)
with pytest.raises(ValueError):
await db.execute_write_fn(read_then_fail)
for cursor in captured:
with pytest.raises(sqlite3.ProgrammingError):
cursor.fetchone()
def test_startup_from_several_event_loops_runs_once(monkeypatch):
# _startup_sequence() serialized startup with an asyncio.Lock: callers
# on a second event loop got "is bound to a different event loop" (or,
# free-threaded, waited forever), and invoke_startup() itself let two
# loops run every startup hook twice
ds = Datasette(memory=True)
calls = []
original = Datasette._apply_column_types_config
async def slow(self):
calls.append(threading.current_thread().name)
await asyncio.sleep(0.3)
return await original(self)
monkeypatch.setattr(Datasette, "_apply_column_types_config", slow)
def starter(use_sequence):
async def run():
if use_sequence:
await ds._startup_sequence()
else:
await ds.invoke_startup()
assert ds._startup_invoked
return (await ds.client.get("/_memory.json")).status_code
return run
statuses = run_in_threads(starter(True), starter(True), starter(False))
assert statuses == [200, 200, 200]
assert len(calls) == 1
ds.close()
@pytest.mark.asyncio
async def test_startup_hook_can_make_requests():
# A request made from inside startup (same task) must not wait for
# startup to finish - that would wait forever
from datasette import hookimpl
from datasette.plugins import pm
statuses = []
class Plugin:
__name__ = "StartupRequestPlugin"
@hookimpl
def startup(self, datasette):
async def inner():
statuses.append(
(await datasette.client.get("/-/versions.json")).status_code
)
return inner
pm.register(Plugin(), name="startup_request_plugin")
try:
ds = Datasette(memory=True)
await asyncio.wait_for(ds.invoke_startup(), 10)
assert statuses == [200]
ds.close()
finally:
pm.unregister(name="startup_request_plugin")

View file

@ -0,0 +1,69 @@
"""
A fast, fixed-seed subset of the connection torture harness
(tests/connection_torture.py). Each scenario runs in its own process, so a
segfault or a hang fails one test instead of killing the test run.
Several event loops (threads) drive one Datasette with extreme settings
(smallest pool, 1ms idle timeout, 10ms schema polling) through reads,
writes of every kind, DDL, isolated functions, analyze_sql, cancellation,
add/remove_database, scratch databases, external schema changes, files
replaced and deleted, Database.close() and Datasette.close() mid-flight,
and non-threaded mode. See the harness docstring for what is checked.
"""
import json
import os
import signal
import subprocess
import sys
import pytest
HARNESS = os.path.join(os.path.dirname(__file__), "connection_torture.py")
@pytest.mark.parametrize(
"scenario,seed",
[
("mixed", 101),
("lifecycle", 102),
("close", 103),
("cancel", 104),
("pool", 105),
("nothreads", 106),
("writes", 107),
],
)
def test_connection_torture(scenario, seed):
try:
proc = subprocess.run(
[
sys.executable,
HARNESS,
scenario,
"--seed",
str(seed),
"--duration",
"1.5",
],
capture_output=True,
text=True,
timeout=150,
check=False,
)
except subprocess.TimeoutExpired as e: # pragma: no cover
pytest.fail(f"{scenario} hung:\n{(e.stderr or '')[-4000:]}")
if proc.returncode < 0: # pragma: no cover
pytest.fail(
f"{scenario} crashed with {signal.Signals(-proc.returncode).name}:\n"
f"{proc.stderr[-4000:]}"
)
summary = json.loads(proc.stdout.strip().splitlines()[-1])
problems = "\n".join(
f"[{p['kind']}] {p['message'][:600]}" for p in summary["problems"]
)
assert proc.returncode == 0, f"{scenario} seed {seed}:\n{problems}"
assert summary["ok"]
# It did something (the slow-query scenarios do a few dozen operations
# in 1.5s on a busy machine, the others hundreds)
assert sum(summary["counts"].values()) > 10

View file

@ -625,7 +625,9 @@ async def test_default_schema_watch_setting(tmp_path, db_path, setting, expected
Database(ds, path=other), name="polled", schema_watch="external"
)
assert polled._watch_state.mode == "external"
owned = ds.add_database(Database(ds, path=other), name="owned", schema_watch="owned")
owned = ds.add_database(
Database(ds, path=other), name="owned", schema_watch="owned"
)
assert owned._watch_state.mode == "owned"
ds.close()