mirror of
https://github.com/simonw/datasette.git
synced 2026-10-19 07:47:00 +02:00
Adds tests/connection_budget.py, a helper that records which databases a block of code opens, reads, writes or ATTACHes, and tests/test_connection_budget.py which asserts budgets for ~60 endpoints, startup, sweeps and telemetry with 50 attached databases. Fixes for every all-database loop it found: - Index page: tables, views, hidden tables, columns, primary keys, FTS tables and foreign-key relationship counts come from the _internal catalog (a few queries for any number of databases). Row counts are only run when at most 20 databases need counting (COUNT_MAX_DATABASES); above that the page shows table counts plus counts already known. - Derived-table permissions: allowed_resources() no longer queries every database per page; dependency maps are computed by the SchemaWatcher from the rows it scans, cached per database, and read back from the catalog for databases restored from a persisted catalog. - catalog_tables gains a type column (PRAGMA table_list: table, virtual, shadow) so hidden shadow tables can be identified without a connection. - PaginatedResources.all() fetches pages of 1000 after the first. - /-/api and /-/debug/autocomplete use the catalog; /-/schema does one permission query and skips databases with nothing visible. - /-/versions computes SQLite details once per instance, without crossdb ATTACHes; detect_json1() caches its answer. - /-/databases only stats/hashes databases the actor can see. - Immutable table counts are computed when first needed, not at startup. - check_databases() (serve, --get) reuses the catalog scan instead of opening every database a second time, and reports "file is not a database" as a usage error. - SchemaWatcher: failed scans are retried when the file changes, not on every sweep; scan connections skip crossdb ATTACH; catalog_generation counter for caches. - The SQL write UI no longer forces a stat sweep of every database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""
|
|
Tests for wiring Datasette startup (setup_db table counts + invoke_startup)
|
|
into the ASGI lifespan protocol.
|
|
|
|
These exercise Datasette._startup_sequence() via three different callers:
|
|
- AsgiLifespan, by hand-driving lifespan.startup messages (no HTTP request)
|
|
- AsgiRunOnFirstRequest, the fallback for hosts that never send lifespan
|
|
events (this is what DatasetteClient / plain httpx2.ASGITransport uses)
|
|
- Both at once, to prove startup hooks run at most once
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import sqlite3
|
|
|
|
import httpx2
|
|
import pytest
|
|
|
|
from datasette import hookimpl
|
|
from datasette.app import Datasette
|
|
from datasette.database import Database
|
|
from datasette.plugins import pm
|
|
|
|
|
|
async def _drive_lifespan_startup(app):
|
|
"""Send a single lifespan.startup message into app's ASGI lifespan loop
|
|
and return the list of messages sent back - without ever sending
|
|
lifespan.shutdown. Mirrors what a real server does: after startup
|
|
completes it parks waiting for the next event. We cancel that wait
|
|
once we've observed the startup response, rather than closing the
|
|
Datasette instance down with a shutdown message.
|
|
"""
|
|
messages_sent = []
|
|
startup_responded = asyncio.Event()
|
|
delivered = False
|
|
|
|
async def receive():
|
|
nonlocal delivered
|
|
if not delivered:
|
|
delivered = True
|
|
return {"type": "lifespan.startup"}
|
|
# No further messages: block until the task is cancelled below,
|
|
# same as a real server parked waiting for lifespan.shutdown.
|
|
await asyncio.Event().wait()
|
|
|
|
async def send(message):
|
|
messages_sent.append(message)
|
|
startup_responded.set()
|
|
|
|
task = asyncio.create_task(app({"type": "lifespan"}, receive, send))
|
|
try:
|
|
await asyncio.wait_for(startup_responded.wait(), timeout=5)
|
|
finally:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
return messages_sent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_startup_runs_before_any_request():
|
|
ds = Datasette(memory=True)
|
|
assert ds._startup_invoked is False
|
|
app = ds.app()
|
|
|
|
messages = await _drive_lifespan_startup(app)
|
|
|
|
assert {"type": "lifespan.startup.complete"} in messages
|
|
assert ds._startup_invoked is True
|
|
# Internal catalog tables should be populated too, entirely without an
|
|
# HTTP request having been made.
|
|
internal_db = ds.get_internal_database()
|
|
databases = await internal_db.execute("select * from catalog_databases")
|
|
assert len(databases.rows) >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_startup_failure_reports_lifespan_startup_failed():
|
|
class RaisingStartupPlugin:
|
|
__name__ = "RaisingStartupPlugin"
|
|
|
|
@hookimpl
|
|
def startup(self, datasette):
|
|
async def inner():
|
|
raise RuntimeError("boom from startup hook")
|
|
|
|
return inner
|
|
|
|
ds = Datasette(memory=True)
|
|
pm.register(RaisingStartupPlugin(), name="raising_startup_plugin")
|
|
try:
|
|
app = ds.app()
|
|
messages = await _drive_lifespan_startup(app)
|
|
finally:
|
|
pm.unregister(name="raising_startup_plugin")
|
|
|
|
assert messages == [
|
|
{"type": "lifespan.startup.failed", "message": "boom from startup hook"}
|
|
]
|
|
# The exception happened before invoke_startup() got to the end of its
|
|
# body, so startup is not considered to have completed.
|
|
assert ds._startup_invoked is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_runs_exactly_once_across_lifespan_and_first_request():
|
|
call_count = {"n": 0}
|
|
|
|
class CountingStartupPlugin:
|
|
__name__ = "CountingStartupPlugin"
|
|
|
|
@hookimpl
|
|
def startup(self, datasette):
|
|
async def inner():
|
|
call_count["n"] += 1
|
|
|
|
return inner
|
|
|
|
ds = Datasette(memory=True)
|
|
pm.register(CountingStartupPlugin(), name="counting_startup_plugin")
|
|
try:
|
|
# Build the ASGI app once, the way a real deployment does - and
|
|
# reuse the SAME app instance for both the lifespan drive and the
|
|
# HTTP requests below, since a fresh ds.app() call would reset the
|
|
# AsgiRunOnFirstRequest fallback's state.
|
|
app = ds.app()
|
|
|
|
messages = await _drive_lifespan_startup(app)
|
|
assert {"type": "lifespan.startup.complete"} in messages
|
|
assert call_count["n"] == 1
|
|
|
|
# A first HTTP request (as if the host never sent lifespan events,
|
|
# or lifespan already ran) should not run the hook again.
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(
|
|
transport=transport, base_url="http://localhost"
|
|
) as client:
|
|
response1 = await client.get("/-/versions.json")
|
|
assert response1.status_code == 200
|
|
# ... nor should a second, repeat request.
|
|
response2 = await client.get("/-/versions.json")
|
|
assert response2.status_code == 200
|
|
finally:
|
|
pm.unregister(name="counting_startup_plugin")
|
|
|
|
assert call_count["n"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_lifespan_first_request_still_triggers_startup():
|
|
# Pin today's behavior: a client that never drives ASGI lifespan events
|
|
# at all (like httpx2.ASGITransport, which DatasetteClient uses) still
|
|
# gets startup armed by the AsgiRunOnFirstRequest fallback.
|
|
ds = Datasette(memory=True)
|
|
assert ds._startup_invoked is False
|
|
app = ds.app()
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(
|
|
transport=transport, base_url="http://localhost"
|
|
) as client:
|
|
response = await client.get("/-/versions.json")
|
|
assert response.status_code == 200
|
|
|
|
assert ds._startup_invoked is True
|
|
internal_db = ds.get_internal_database()
|
|
databases = await internal_db.execute("select * from catalog_databases")
|
|
assert len(databases.rows) >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datasette_client_first_request_triggers_startup():
|
|
# Same as above, but through the real DatasetteClient (ds.client) that
|
|
# plugins and tests actually use, to confirm nothing regressed there.
|
|
ds = Datasette(memory=True)
|
|
assert ds._startup_invoked is False
|
|
response = await ds.client.get("/-/versions.json")
|
|
assert response.status_code == 200
|
|
assert ds._startup_invoked is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_first_requests_all_wait_for_slow_startup():
|
|
call_count = {"n": 0}
|
|
|
|
class SlowStartupPlugin:
|
|
__name__ = "SlowStartupPlugin"
|
|
|
|
@hookimpl
|
|
def startup(self, datasette):
|
|
async def inner():
|
|
call_count["n"] += 1
|
|
await asyncio.sleep(0.2)
|
|
|
|
return inner
|
|
|
|
ds = Datasette(memory=True)
|
|
pm.register(SlowStartupPlugin(), name="slow_startup_plugin")
|
|
try:
|
|
app = ds.app()
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(
|
|
transport=transport, base_url="http://localhost"
|
|
) as client:
|
|
responses = await asyncio.gather(
|
|
*[client.get("/-/versions.json") for _ in range(10)]
|
|
)
|
|
finally:
|
|
pm.unregister(name="slow_startup_plugin")
|
|
|
|
# Every one of the 10 simultaneous first requests must have blocked
|
|
# until startup actually finished, not raced ahead of it.
|
|
assert all(response.status_code == 200 for response in responses)
|
|
assert call_count["n"] == 1
|
|
assert ds._startup_invoked is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_immutable_table_counts_computed_when_first_needed(tmp_path, monkeypatch):
|
|
# Immutable databases used to have their table counts precomputed by
|
|
# _startup_sequence(), opening every immutable database at startup.
|
|
# They are now computed the first time a page needs them, with the same
|
|
# generous time limit, and cached.
|
|
db_path = tmp_path / "immutable.db"
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute("create table t (id integer primary key)")
|
|
conn.execute("insert into t default values")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
ds = Datasette([], immutables=[str(db_path)])
|
|
|
|
call_count = {"n": 0}
|
|
original_table_counts = Database.table_counts
|
|
|
|
async def counting_table_counts(self, *args, **kwargs):
|
|
call_count["n"] += 1
|
|
return await original_table_counts(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Database, "table_counts", counting_table_counts)
|
|
|
|
await ds.invoke_startup()
|
|
await ds._startup_sequence()
|
|
assert ds._startup_invoked is True
|
|
assert ds._setup_db_done is True
|
|
assert call_count["n"] == 0
|
|
assert ds.get_database("immutable").cached_table_counts is None
|
|
|
|
response = await ds.client.get("/.json")
|
|
assert response.json()["databases"][0]["table_rows_sum"] == 1
|
|
assert call_count["n"] == 1
|
|
assert ds.get_database("immutable").cached_table_counts == {"t": 1}
|
|
|
|
# Cached from then on
|
|
await ds.client.get("/immutable.json")
|
|
assert ds.get_database("immutable").cached_table_counts == {"t": 1}
|
|
ds.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_asgi_wrapper_runs_after_startup_fallback_path():
|
|
class AssertStartupPlugin:
|
|
__name__ = "AssertStartupPlugin"
|
|
|
|
@hookimpl
|
|
def asgi_wrapper(self, datasette):
|
|
def wrap(app):
|
|
async def check_startup(scope, receive, send):
|
|
if scope["type"] == "http":
|
|
assert (
|
|
datasette._startup_invoked is True
|
|
), "asgi_wrapper saw an http scope before startup completed"
|
|
await app(scope, receive, send)
|
|
|
|
return check_startup
|
|
|
|
return wrap
|
|
|
|
ds = Datasette(memory=True)
|
|
pm.register(AssertStartupPlugin(), name="assert_startup_plugin")
|
|
try:
|
|
assert ds._startup_invoked is False
|
|
app = ds.app()
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(
|
|
transport=transport, base_url="http://localhost"
|
|
) as client:
|
|
response = await client.get("/-/versions.json")
|
|
assert response.status_code == 200
|
|
finally:
|
|
pm.unregister(name="assert_startup_plugin")
|
|
|
|
assert ds._startup_invoked is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_circuit_wrapper_no_longer_defers_startup():
|
|
# Middleware that returns a response before getting to the rest of
|
|
# Datasette should still cause _startup_invoked=True
|
|
class ShortCircuitPlugin:
|
|
__name__ = "ShortCircuitPlugin"
|
|
|
|
@hookimpl
|
|
def asgi_wrapper(self, datasette):
|
|
def wrap(app):
|
|
async def forbidden(scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await app(scope, receive, send)
|
|
return
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 403,
|
|
"headers": [[b"content-type", b"text/plain"]],
|
|
}
|
|
)
|
|
await send(
|
|
{
|
|
"type": "http.response.body",
|
|
"body": b"Forbidden",
|
|
}
|
|
)
|
|
|
|
return forbidden
|
|
|
|
return wrap
|
|
|
|
ds = Datasette(memory=True)
|
|
pm.register(ShortCircuitPlugin(), name="short_circuit_plugin")
|
|
try:
|
|
assert ds._startup_invoked is False
|
|
app = ds.app()
|
|
transport = httpx2.ASGITransport(app=app)
|
|
async with httpx2.AsyncClient(
|
|
transport=transport, base_url="http://localhost"
|
|
) as client:
|
|
response = await client.get("/-/versions.json")
|
|
assert response.status_code == 403
|
|
finally:
|
|
pm.unregister(name="short_circuit_plugin")
|
|
|
|
assert ds._startup_invoked is True
|