mirror of
https://github.com/simonw/datasette.git
synced 2026-10-23 09:47:01 +02:00
250 lines
8.5 KiB
Python
250 lines
8.5 KiB
Python
import asyncio
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from datasette.app import Datasette
|
|
from datasette.database import Database, DatasetteClosedError
|
|
from datasette.write_budget import RETIRE_WRITER, DatabaseAdmissionTimeout
|
|
|
|
|
|
def make_databases(tmp_path, count=2, **settings):
|
|
paths = []
|
|
for i in range(count):
|
|
path = tmp_path / f"shutdown{i}.db"
|
|
with sqlite3.connect(path) as conn:
|
|
conn.execute("create table t(id)")
|
|
conn.close()
|
|
paths.append(str(path))
|
|
ds = Datasette(paths, settings={"schema_watch_interval_ms": 0, **settings})
|
|
return ds, [ds.get_database(f"shutdown{i}") for i in range(count)]
|
|
|
|
|
|
async def wait_for(predicate):
|
|
deadline = time.monotonic() + 5
|
|
while not predicate():
|
|
assert time.monotonic() < deadline
|
|
await asyncio.sleep(0.005)
|
|
|
|
|
|
def rows(db):
|
|
with sqlite3.connect(db.path) as conn:
|
|
result = conn.execute("select id from t order by rowid").fetchall()
|
|
conn.close()
|
|
return [row[0] for row in result]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("retirement_requested", [False, True])
|
|
async def test_close_drains_writes_before_worker_admission(
|
|
tmp_path, monkeypatch, retirement_requested
|
|
):
|
|
ds, (db, _) = make_databases(tmp_path, max_write_connections=1)
|
|
admitted = threading.Event()
|
|
release = threading.Event()
|
|
run = ds._write_budget._run
|
|
|
|
def paused_scheduler():
|
|
admitted.set()
|
|
assert release.wait(5)
|
|
run()
|
|
|
|
monkeypatch.setattr(ds._write_budget, "_run", paused_scheduler)
|
|
replies = []
|
|
closing = None
|
|
try:
|
|
for i in range(20):
|
|
_, reply = await db._send_to_write_thread(
|
|
lambda conn, i=i: conn.execute("insert into t values (?)", [i]),
|
|
block=False,
|
|
)
|
|
replies.append(reply)
|
|
assert admitted.wait(1)
|
|
assert db._write_thread is None
|
|
if retirement_requested:
|
|
# A previous retirement request can precede tasks accepted later.
|
|
with db._write_queue.mutex:
|
|
db._write_queue.queue.appendleft(RETIRE_WRITER)
|
|
closing = asyncio.create_task(asyncio.to_thread(db.close))
|
|
await wait_for(lambda: db._closed)
|
|
release.set()
|
|
await asyncio.wait_for(closing, 5)
|
|
await asyncio.gather(*replies)
|
|
assert rows(db) == list(range(20))
|
|
assert ds._write_budget.snapshot()["writers"] == 0
|
|
assert ds._write_budget.snapshot()["stats"]["peak_writers"] == 1
|
|
with pytest.raises(DatasetteClosedError):
|
|
await db.execute_write("insert into t values (21)")
|
|
finally:
|
|
release.set()
|
|
if closing is not None:
|
|
await closing
|
|
await asyncio.gather(*replies, return_exceptions=True)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_waits_for_budget_capacity_then_drains(tmp_path):
|
|
ds, (first, waiting) = make_databases(tmp_path, max_write_connections=1)
|
|
entered, release = threading.Event(), threading.Event()
|
|
|
|
def slow(conn):
|
|
entered.set()
|
|
assert release.wait(5)
|
|
|
|
_, first_reply = await first._send_to_write_thread(slow, block=False)
|
|
closing = None
|
|
waiting_reply = None
|
|
try:
|
|
assert await asyncio.to_thread(entered.wait, 5)
|
|
_, waiting_reply = await waiting._send_to_write_thread(
|
|
lambda conn: conn.execute("insert into t values (1)"), block=False
|
|
)
|
|
closing = asyncio.create_task(asyncio.to_thread(waiting.close))
|
|
await wait_for(lambda: waiting._closed)
|
|
assert ds._write_budget.snapshot()["pending"] == 1
|
|
release.set()
|
|
await asyncio.wait_for(closing, 5)
|
|
await asyncio.gather(first_reply, waiting_reply)
|
|
assert rows(waiting) == [1]
|
|
assert ds._write_budget.snapshot()["stats"]["peak_writers"] == 1
|
|
finally:
|
|
release.set()
|
|
if closing is not None:
|
|
await closing
|
|
await asyncio.gather(
|
|
first_reply,
|
|
*([waiting_reply] if waiting_reply else []),
|
|
return_exceptions=True,
|
|
)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_preserves_admission_deadline_while_capacity_pinned(tmp_path):
|
|
ds, (waiting, _) = make_databases(
|
|
tmp_path, max_write_connections=1, write_queue_timeout_ms=50
|
|
)
|
|
memory = ds.add_memory_database("pinned")
|
|
ran = []
|
|
try:
|
|
await memory.execute_write("create table t(id)")
|
|
_, reply = await waiting._send_to_write_thread(
|
|
lambda conn: ran.append(True), block=False
|
|
)
|
|
await asyncio.wait_for(asyncio.to_thread(waiting.close), 2)
|
|
with pytest.raises(DatabaseAdmissionTimeout):
|
|
await reply
|
|
assert not ran
|
|
assert ds._write_budget.snapshot()["pending"] == 0
|
|
assert ds._write_budget.snapshot()["stats"]["peak_writers"] == 1
|
|
finally:
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datasette_shutdown_releases_later_memory_writer_before_drain(tmp_path):
|
|
ds, (waiting, _) = make_databases(
|
|
tmp_path, max_write_connections=1, write_queue_timeout_ms=1000
|
|
)
|
|
memory = ds.add_memory_database("pinned")
|
|
try:
|
|
await memory.execute_write("create table t(id)")
|
|
_, reply = await waiting._send_to_write_thread(
|
|
lambda conn: conn.execute("insert into t values (1)"), block=False
|
|
)
|
|
await asyncio.wait_for(asyncio.to_thread(ds.close), 5)
|
|
await reply
|
|
assert rows(waiting) == [1]
|
|
assert ds._write_budget.snapshot()["writers"] == 0
|
|
assert ds._write_budget.snapshot()["stats"]["peak_writers"] == 1
|
|
finally:
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("database_kind", ["file", "temporary", "internal"])
|
|
async def test_close_timeout_preserves_running_connection(
|
|
tmp_path, monkeypatch, database_kind
|
|
):
|
|
import datasette.database
|
|
|
|
monkeypatch.setattr(datasette.database, "WRITE_SHUTDOWN_TIMEOUT", 0.05)
|
|
ds, (db, _) = make_databases(tmp_path, max_write_connections=1)
|
|
if database_kind == "temporary":
|
|
db = Database(ds, is_temp_disk=True)
|
|
ds.add_database(db, name="temporary")
|
|
await db.execute_write("create table t(id)")
|
|
elif database_kind == "internal":
|
|
db = ds._internal_database
|
|
await db.execute_write("create table t(id)")
|
|
entered, release = threading.Event(), threading.Event()
|
|
|
|
def slow(conn):
|
|
entered.set()
|
|
assert release.wait(5)
|
|
conn.execute("insert into t values (1)")
|
|
return conn.execute("select id from t").fetchall()[0][0]
|
|
|
|
_, running = await db._send_to_write_thread(slow, block=False)
|
|
queued = None
|
|
try:
|
|
assert await asyncio.to_thread(entered.wait, 5)
|
|
if database_kind != "internal":
|
|
_, queued = await db._send_to_write_thread(
|
|
lambda conn: pytest.fail("cancelled callback ran"), block=False
|
|
)
|
|
await asyncio.wait_for(asyncio.to_thread(db.close), 2)
|
|
if queued is not None:
|
|
with pytest.raises(DatasetteClosedError):
|
|
await queued
|
|
assert Path(db.path).exists()
|
|
assert not running.done()
|
|
release.set()
|
|
assert await running == 1
|
|
if database_kind == "file":
|
|
assert rows(db) == [1]
|
|
else:
|
|
await wait_for(lambda: not Path(db.path).exists())
|
|
await wait_for(lambda: db._write_thread is None)
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(
|
|
running, *([queued] if queued else []), return_exceptions=True
|
|
)
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_timeout_during_write_connection_open(tmp_path, monkeypatch):
|
|
import datasette.database
|
|
|
|
monkeypatch.setattr(datasette.database, "WRITE_SHUTDOWN_TIMEOUT", 0.05)
|
|
ds, (db, _) = make_databases(tmp_path)
|
|
opened, release = threading.Event(), threading.Event()
|
|
connect = db.connect
|
|
|
|
def paused_connect(**kwargs):
|
|
conn = connect(**kwargs)
|
|
opened.set()
|
|
assert release.wait(5)
|
|
return conn
|
|
|
|
monkeypatch.setattr(db, "connect", paused_connect)
|
|
_, reply = await db._send_to_write_thread(
|
|
lambda conn: conn.execute("insert into t values (1)"), block=False
|
|
)
|
|
try:
|
|
assert await asyncio.to_thread(opened.wait, 5)
|
|
await asyncio.wait_for(asyncio.to_thread(db.close), 2)
|
|
release.set()
|
|
await reply
|
|
assert rows(db) == [1]
|
|
await wait_for(lambda: db._write_thread is None)
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(reply, return_exceptions=True)
|
|
ds.close()
|