datasette/tests/test_connection_recovery.py

112 lines
3.8 KiB
Python

import errno
import sqlite3
import pytest
from datasette.app import Datasette
from datasette.connection_pool import ConnectionLeaseError
@pytest.fixture
def database_file(tmp_path):
path = tmp_path / "data.db"
conn = sqlite3.connect(path)
conn.executescript("create table t(id); insert into t values (1), (2)")
conn.close()
return str(path)
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["read", "write", "isolated"])
async def test_saved_methods_and_cursors_cannot_escape_callback(database_file, kind):
ds = Datasette([database_file], settings={"schema_watch_interval_ms": 0})
db = ds.get_database("data")
saved = {}
def callback(conn):
saved["commit"] = conn.commit
saved["cursor"] = conn.execute("select * from t")
saved["fetch"] = saved["cursor"].fetchone
saved["connection"] = saved["cursor"].connection
assert saved["fetch"]()[0] == 1
method = {
"read": db.execute_fn,
"write": db.execute_write_fn,
"isolated": db.execute_isolated_fn,
}[kind]
try:
await method(callback)
for call in (saved["commit"], lambda: saved["connection"].execute("select 1")):
with pytest.raises(ConnectionLeaseError):
call()
for call in (saved["fetch"], lambda: next(saved["cursor"])):
with pytest.raises((ConnectionLeaseError, sqlite3.ProgrammingError)):
call()
assert (await db.execute("select count(*) from t")).single_value() == 2
finally:
ds.close()
@pytest.mark.asyncio
async def test_read_result_containing_cursor_is_rejected(database_file):
ds = Datasette([database_file])
db = ds.get_database("data")
try:
with pytest.raises(ConnectionLeaseError):
await db.execute_fn(lambda c: {"nested": [c.execute("select * from t")]})
result = await db.execute_write_fn(
lambda c: {"nested": [c.execute("insert into t values (3)")]}
)
assert result["nested"][0].rowcount == 1
with pytest.raises((ConnectionLeaseError, sqlite3.ProgrammingError)):
result["nested"][0].fetchone()
assert (await db.execute("select count(*) from t")).single_value() == 3
finally:
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure",
[
sqlite3.OperationalError("unable to open database file"),
OSError(errno.EMFILE, "Too many open files"),
],
)
async def test_schema_recovers_after_resource_error_without_file_change(
database_file, monkeypatch, failure
):
ds = Datasette([database_file], settings={"schema_watch_interval_ms": 0})
try:
await ds.invoke_startup()
db = ds.get_database("data")
conn = sqlite3.connect(database_file)
conn.execute("create table added_externally(id)")
conn.close()
watcher = ds._schema_watcher
original = watcher._connect
def fail(database, prepare):
if database is db and prepare:
raise failure
return original(database, prepare)
with monkeypatch.context() as patch:
patch.setattr(watcher, "_connect", fail)
await ds.refresh_schemas()
state = db._watch_state
assert state.needs_scan
# Move past the retry backoff and fingerprint race window without
# touching the file or relying on timing-sensitive sleeps.
state.retry_at = 0
state.fp_racy = False
await watcher.sweep(background=True)
tables = await ds.get_internal_database().execute(
"select table_name from catalog_tables where database_name=?", ["data"]
)
assert {row[0] for row in tables.rows} == {"t", "added_externally"}
assert state.error is None
assert not state.needs_scan
finally:
ds.close()