mirror of
https://github.com/simonw/datasette.git
synced 2026-10-20 16:26:58 +02:00
Connection budget tests for scratch databases
- Startup with 1000 scratch databases (threaded and num_sql_threads=0): Datasette() opens none of them; _startup_sequence() with a temporary internal database opens each exactly once (its catalog scan) with no pooled reads, writes or ATTACH; the index page, /-/databases.json, /-/api, /-/allowed, /-/jump, /-/check, /-/permissions and idle sweeps open none. - Restart with a persisted internal database opens no scratch database, even for one written after its last scan (closing fingerprints). - check_databases() only opens each scratch database for the catalog scan and does not fail on a broken scratch file. - create, write, read, list, rename and delete touch only the scratch database concerned (list touches nothing); direct sqlite3.connect() calls are confined to the scratch directory (registry, WAL setup, rename checkpoint). - docs: serve does not check scratch databases; listing pages do not open them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv
This commit is contained in:
parent
c786d91cf9
commit
a509b81f2c
2 changed files with 235 additions and 1 deletions
|
|
@ -1412,7 +1412,7 @@ Use a directory just for scratch databases. Only one Datasette instance can use
|
||||||
|
|
||||||
The directory contains one ``<name>.db`` file per scratch database, plus a registry (``.datasette-scratch.db``) that records each database's owner, metadata, creation time and when it was last used. Scratch databases are created in WAL mode (rollback journal mode with ``--nolock``).
|
The directory contains one ``<name>.db`` file per scratch database, plus a registry (``.datasette-scratch.db``) that records each database's owner, metadata, creation time and when it was last used. Scratch databases are created in WAL mode (rollback journal mode with ``--nolock``).
|
||||||
|
|
||||||
**Startup.** Existing scratch databases are attached when Datasette starts without opening any of them: Datasette reads the registry and lists the directory. Like any database, a scratch database gets connections and a write thread only while it is being used, and they are closed again after :ref:`setting_connection_idle_timeout_ms`. With a persistent internal database (``--internal``) and a clean shutdown, the table catalog for scratch databases is restored at startup without opening them either. With the default temporary internal database each scratch database is opened briefly, once, at startup to read its schema - as are, after a crash, the ones that were written to since their schema was last read.
|
**Startup.** Existing scratch databases are attached when Datasette starts without opening any of them: Datasette reads the registry and lists the directory. Like any database, a scratch database gets connections and a write thread only while it is being used, and they are closed again after :ref:`setting_connection_idle_timeout_ms`. With a persistent internal database (``--internal``) and a clean shutdown, the table catalog for scratch databases is restored at startup without opening them either. With the default temporary internal database each scratch database is opened briefly, once, at startup to read its schema - as are, after a crash, the ones that were written to since their schema was last read. ``datasette serve`` does not run its startup check on scratch databases: a scratch database whose file cannot be read is reported in the log and does not stop the server from starting. Pages that list every database, such as the index page, read scratch databases' tables from the catalog and do not open them.
|
||||||
|
|
||||||
**The catalog** of a scratch database's tables is kept current by its own writes (schema watch mode ``"owned"``): a table created through any of the write methods is in the catalog before the write returns. If another process writes to a scratch database file directly, Datasette will not notice schema changes.
|
**The catalog** of a scratch database's tables is kept current by its own writes (schema watch mode ``"owned"``): a table created through any of the write methods is in the catalog before the write returns. If another process writes to a scratch database file directly, Datasette will not notice schema changes.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -650,3 +650,237 @@ def test_connection_budget_helper_counts(db_files):
|
||||||
budget = asyncio.run(run())
|
budget = asyncio.run(run())
|
||||||
assert sum(budget.probes.values()) == 1
|
assert sum(budget.probes.values()) == 1
|
||||||
assert json.dumps(budget.summary())
|
assert json.dumps(budget.summary())
|
||||||
|
|
||||||
|
|
||||||
|
# Scratch databases: creating, using, listing, renaming and deleting one
|
||||||
|
# touches only that database, and startup with many of them opens each one
|
||||||
|
# at most once (none at all with a persisted catalog)
|
||||||
|
|
||||||
|
|
||||||
|
SCRATCH_N = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _scratch_probes_only(budget, scratch_dir):
|
||||||
|
"""Direct sqlite3.connect() calls are allowed for the scratch registry
|
||||||
|
and the scratch files themselves (creating a file in WAL mode, the
|
||||||
|
rename checkpoint) - nothing else."""
|
||||||
|
outside = [p for p in budget.probes if not p.startswith(str(scratch_dir))]
|
||||||
|
assert not outside, f"probes outside the scratch directory: {outside}"
|
||||||
|
|
||||||
|
|
||||||
|
def _no_resources_held(db):
|
||||||
|
assert db._all_connections == []
|
||||||
|
assert db._read_pool_state is None
|
||||||
|
assert db._write_thread is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def scratch_template_dir(tmp_path_factory):
|
||||||
|
"""SCRATCH_N scratch database files, copied in by hand (adopted at the
|
||||||
|
first startup). Copies of one template, so building it is fast."""
|
||||||
|
directory = tmp_path_factory.mktemp("scratch_template")
|
||||||
|
template = directory / "template.sqlite"
|
||||||
|
conn = sqlite3.connect(template)
|
||||||
|
conn.executescript("""
|
||||||
|
create table t (id integer primary key, v text);
|
||||||
|
insert into t (v) values ('a'), ('b');
|
||||||
|
create view tv as select * from t;
|
||||||
|
""")
|
||||||
|
conn.close()
|
||||||
|
scratch = directory / "scratch"
|
||||||
|
scratch.mkdir()
|
||||||
|
for i in range(SCRATCH_N):
|
||||||
|
shutil.copy(template, scratch / f"s{i:04d}.db")
|
||||||
|
old = time.time() - 60
|
||||||
|
for path in scratch.iterdir():
|
||||||
|
os.utime(path, (old, old))
|
||||||
|
return scratch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scratch_1000(scratch_template_dir, tmp_path):
|
||||||
|
target = tmp_path / "scratch"
|
||||||
|
shutil.copytree(scratch_template_dir, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# Pages that list or check every database (see
|
||||||
|
# test_instance_pages_open_no_user_database and the index tests above)
|
||||||
|
SCRATCH_INSTANCE_PAGES = [
|
||||||
|
("/", False),
|
||||||
|
("/", True),
|
||||||
|
("/.json", False),
|
||||||
|
("/-/databases.json", False),
|
||||||
|
("/-/api", True),
|
||||||
|
("/-/versions.json", False),
|
||||||
|
("/-/jump.json?q=t", False),
|
||||||
|
("/-/allowed.json?action=view-table", False),
|
||||||
|
("/-/allowed.json?action=view-table", True),
|
||||||
|
("/-/allowed.json?action=view-database", False),
|
||||||
|
("/-/check.json?action=view-table&parent=s0001&child=t", True),
|
||||||
|
("/-/permissions", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _scratch_names(ds):
|
||||||
|
return sorted(name for name, db in ds.databases.items() if db.is_scratch)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("num_sql_threads", [0, 3])
|
||||||
|
async def test_startup_with_many_scratch_databases(scratch_1000, num_sql_threads):
|
||||||
|
settings = {"schema_watch_interval_ms": 0, "num_sql_threads": num_sql_threads}
|
||||||
|
with connection_budget() as budget:
|
||||||
|
ds = Datasette(scratch_dir=str(scratch_1000), settings=settings)
|
||||||
|
# Attached by reading the registry and listing the directory: no
|
||||||
|
# scratch database opened, read or written
|
||||||
|
assert len(_scratch_names(ds)) == SCRATCH_N
|
||||||
|
assert not any(n.startswith("s") for n in budget.touched()), budget.summary()
|
||||||
|
_scratch_probes_only(budget, scratch_1000)
|
||||||
|
assert ds._read_pool_or_none is None
|
||||||
|
|
||||||
|
with connection_budget() as budget:
|
||||||
|
await ds._startup_sequence()
|
||||||
|
# With a temporary internal database each scratch database is opened
|
||||||
|
# once, for its catalog scan - no pooled reads, writes or ATTACH
|
||||||
|
scratch_opened = {k: v for k, v in budget.opened.items() if k.startswith("s")}
|
||||||
|
assert len(scratch_opened) == SCRATCH_N
|
||||||
|
assert set(scratch_opened.values()) == {1}
|
||||||
|
assert set(budget.reads) <= {INTERNAL, "_memory"}, budget.reads
|
||||||
|
assert set(budget.writes) <= {INTERNAL}, budget.writes
|
||||||
|
assert not budget.attached
|
||||||
|
assert not budget.probes, budget.probes
|
||||||
|
for name in _scratch_names(ds)[:: SCRATCH_N // 10]:
|
||||||
|
_no_resources_held(ds.get_database(name))
|
||||||
|
|
||||||
|
# Pages that cover every database, and idle sweeps, open none of them
|
||||||
|
ds.root_enabled = True
|
||||||
|
await ds.client.get("/-/versions.json")
|
||||||
|
with connection_budget() as budget:
|
||||||
|
for path, as_root in SCRATCH_INSTANCE_PAGES:
|
||||||
|
response = await ds.client.get(path, cookies=_root(ds) if as_root else None)
|
||||||
|
assert response.status_code == 200, path
|
||||||
|
await ds._refresh_schemas(background=True)
|
||||||
|
await ds.refresh_schemas(force=True)
|
||||||
|
budget.assert_only("_memory")
|
||||||
|
assert not any(n.startswith("s") for n in budget.opened)
|
||||||
|
data = (await ds.client.get("/.json")).json()
|
||||||
|
s0001 = next(d for d in data["databases"] if d["name"] == "s0001")
|
||||||
|
assert (s0001["tables_count"], s0001["views_count"]) == (1, 1)
|
||||||
|
ds.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restart_with_many_scratch_databases_opens_none(scratch_1000, tmp_path):
|
||||||
|
# Not patching RACY_WINDOW_NS: the files were adopted and scanned just
|
||||||
|
# now, and close() records "closed" fingerprints for owned databases,
|
||||||
|
# which are trusted inside the racy window
|
||||||
|
internal = str(tmp_path / "internal.db")
|
||||||
|
settings = {"schema_watch_interval_ms": 0}
|
||||||
|
ds = Datasette(scratch_dir=str(scratch_1000), internal=internal, settings=settings)
|
||||||
|
await ds.invoke_startup()
|
||||||
|
# Written after its catalog scan - still not rescanned at restart
|
||||||
|
await ds.get_database("s0007").execute_write("insert into t (v) values ('c')")
|
||||||
|
ds.close()
|
||||||
|
|
||||||
|
with connection_budget() as budget:
|
||||||
|
ds = Datasette(
|
||||||
|
scratch_dir=str(scratch_1000), internal=internal, settings=settings
|
||||||
|
)
|
||||||
|
await ds._startup_sequence()
|
||||||
|
response = await ds.client.get("/.json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
budget.assert_only("_memory", probes=1)
|
||||||
|
_scratch_probes_only(budget, scratch_1000)
|
||||||
|
assert ds._schema_watcher.counters["restored_from_persisted"] >= SCRATCH_N
|
||||||
|
# The catalog came back without opening them, and they still work
|
||||||
|
rows = await ds.get_internal_database().execute(
|
||||||
|
"select count(*) from catalog_tables where database_name like 's%'"
|
||||||
|
)
|
||||||
|
assert rows.single_value() == SCRATCH_N
|
||||||
|
db = ds.get_database("s0007")
|
||||||
|
assert (await db.execute("select count(*) from t")).single_value() == 3
|
||||||
|
ds.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_databases_skips_scratch_databases(scratch_1000, tmp_path):
|
||||||
|
from datasette.cli import check_databases
|
||||||
|
|
||||||
|
ds = Datasette(
|
||||||
|
scratch_dir=str(scratch_1000), settings={"schema_watch_interval_ms": 0}
|
||||||
|
)
|
||||||
|
with connection_budget() as budget:
|
||||||
|
await check_databases(ds)
|
||||||
|
# Only the catalog scan (once each): no check_connection() pass over the
|
||||||
|
# scratch databases
|
||||||
|
scratch_opened = {k: v for k, v in budget.opened.items() if k.startswith("s")}
|
||||||
|
assert len(scratch_opened) == SCRATCH_N
|
||||||
|
assert set(scratch_opened.values()) == {1}
|
||||||
|
assert set(budget.reads) <= {INTERNAL, "_memory"}, budget.reads
|
||||||
|
ds.close()
|
||||||
|
|
||||||
|
# A broken file in the scratch directory does not stop the server
|
||||||
|
# starting; it is reported by the catalog scan instead
|
||||||
|
(scratch_1000 / "s0003.db").write_bytes(b"SQLite format 3\x00" + b"x" * 200)
|
||||||
|
ds = Datasette(
|
||||||
|
scratch_dir=str(scratch_1000), settings={"schema_watch_interval_ms": 0}
|
||||||
|
)
|
||||||
|
await check_databases(ds)
|
||||||
|
assert ds.get_database("s0003")._watch_state.error is not None
|
||||||
|
ds.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("num_sql_threads", [0, 3])
|
||||||
|
async def test_scratch_operations_touch_only_their_database(
|
||||||
|
db_files, tmp_path, num_sql_threads
|
||||||
|
):
|
||||||
|
scratch_dir = tmp_path / "scratch"
|
||||||
|
ds = _ds(
|
||||||
|
db_files,
|
||||||
|
scratch_dir=str(scratch_dir),
|
||||||
|
settings={"num_sql_threads": num_sql_threads},
|
||||||
|
)
|
||||||
|
await ds.invoke_startup()
|
||||||
|
for i in range(10):
|
||||||
|
db = await ds.create_scratch_database(f"other{i}")
|
||||||
|
await db.execute_write("create table t (id integer primary key)")
|
||||||
|
|
||||||
|
async with connection_budget() as budget:
|
||||||
|
db = await ds.create_scratch_database("work", actor={"id": "alice"})
|
||||||
|
budget.assert_only("work", probes=10)
|
||||||
|
_scratch_probes_only(budget, scratch_dir)
|
||||||
|
assert await ds.get_internal_database().execute(
|
||||||
|
"select 1 from catalog_databases where database_name = 'work'"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connection_budget() as budget:
|
||||||
|
await db.execute_write("create table t (id integer primary key, v text)")
|
||||||
|
await db.execute_write_many(
|
||||||
|
"insert into t (v) values (?)", [(str(i),) for i in range(10)]
|
||||||
|
)
|
||||||
|
response = await ds.client.get("/work/t.json?_shape=array")
|
||||||
|
assert len(response.json()) == 10
|
||||||
|
response = await ds.client.get("/work.json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
budget.assert_only("work")
|
||||||
|
|
||||||
|
async with connection_budget() as budget:
|
||||||
|
infos = await ds.list_scratch_databases()
|
||||||
|
assert len(infos) == 11
|
||||||
|
budget.assert_only(internal=False)
|
||||||
|
budget.assert_no_connections(internal=False)
|
||||||
|
|
||||||
|
async with connection_budget() as budget:
|
||||||
|
db = await ds.rename_scratch_database("work", "renamed")
|
||||||
|
budget.assert_only("work", "renamed", probes=10)
|
||||||
|
_scratch_probes_only(budget, scratch_dir)
|
||||||
|
assert (await db.execute("select count(*) from t")).single_value() == 10
|
||||||
|
|
||||||
|
async with connection_budget() as budget:
|
||||||
|
await ds.delete_scratch_database("renamed")
|
||||||
|
budget.assert_only("renamed", probes=10)
|
||||||
|
_scratch_probes_only(budget, scratch_dir)
|
||||||
|
assert "renamed" not in ds.databases
|
||||||
|
ds.close()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue