Datasette.close() closes databases, shuts down executor, unlinks temp file

Datasette.close() iterates over every attached Database (including the
internal database), calls Database.close() on each, then shuts down the
ThreadPoolExecutor. Exceptions raised by one Database don't prevent the
others from being closed; the first exception is re-raised afterwards.
Idempotent.

Refs #2692

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-04-16 20:10:18 -07:00
commit 290f27158f
3 changed files with 100 additions and 0 deletions

View file

@ -326,6 +326,7 @@ class Datasette:
default_deny=False,
):
self._startup_invoked = False
self._closed = False
assert config_dir is None or isinstance(
config_dir, Path
), "config_dir= should be a pathlib.Path"
@ -834,6 +835,33 @@ class Datasette:
new_databases.pop(name)
self.databases = new_databases
def close(self):
"""Release all resources held by this Datasette instance.
Closes every attached Database (including the internal database),
shuts down the executor, and unlinks the temporary file used for
the internal database if one was created. Idempotent and one-way.
"""
if self._closed:
return
self._closed = True
first_exception = None
dbs = list(self.databases.values()) + [self._internal_database]
for db in dbs:
try:
db.close()
except Exception as e:
if first_exception is None:
first_exception = e
if self.executor is not None:
try:
self.executor.shutdown(wait=True, cancel_futures=True)
except Exception as e:
if first_exception is None:
first_exception = e
if first_exception is not None:
raise first_exception
def setting(self, key):
return self._settings.get(key, None)

View file

@ -1079,6 +1079,19 @@ The ``name`` and ``route`` parameters are optional and work the same way as they
This removes a database that has been previously added. ``name=`` is the unique name of that database.
.. _datasette_close:
.close()
--------
Release all resources held by this ``Datasette`` instance. This calls :ref:`database_close` on every attached database (including the internal database), shuts down the thread pool executor used to run SQL queries, and unlinks the temporary file used to back the internal database if one was created.
``close()`` is synchronous, idempotent and one-way: after a call to ``close()`` any attempt to use the Datasette instance to execute SQL will raise a ``datasette.database.DatasetteClosedError`` exception. A closed ``Datasette`` cannot be reopened — callers that need a fresh instance should construct a new one.
If a call to ``Database.close()`` on one of the attached databases raises an exception, ``Datasette.close()`` will continue trying to close the remaining databases and will re-raise the first exception after every database has been processed.
When Datasette is being served over ASGI the ``close()`` method is wired up to the lifespan shutdown event, so resources are released cleanly on ``SIGTERM`` / ``SIGINT``.
.. _datasette_track_event:
await .track_event(event)

View file

@ -3,8 +3,10 @@ Tests for the datasette.app.Datasette class
"""
import dataclasses
import os
from datasette import Context
from datasette.app import Datasette, Database, ResourcesSQL
from datasette.database import DatasetteClosedError
from datasette.resources import DatabaseResource
from itsdangerous import BadSignature
import pytest
@ -213,3 +215,60 @@ async def test_allowed_resources_sql(datasette):
assert isinstance(result, ResourcesSQL)
assert "all_rules AS" in result.sql
assert result.params["action"] == "view-table"
@pytest.mark.asyncio
async def test_datasette_close_closes_all_databases_and_executor():
ds = Datasette(memory=True)
await ds.invoke_startup()
# Confirm internal DB has write machinery running
assert ds._internal_database._write_thread is not None
assert ds._internal_database._write_thread.is_alive()
temp_path = ds._internal_database.path
assert os.path.exists(temp_path)
executor = ds.executor
ds.close()
# Executor is shut down
assert executor._shutdown
# All attached Database instances are closed
for db in ds.databases.values():
assert db._closed
assert ds._internal_database._closed
# Temp internal DB file is unlinked
assert not os.path.exists(temp_path)
@pytest.mark.asyncio
async def test_datasette_close_is_idempotent():
ds = Datasette(memory=True)
await ds.invoke_startup()
ds.close()
# Second call should be a no-op
ds.close()
@pytest.mark.asyncio
async def test_datasette_close_raises_on_use():
ds = Datasette(memory=True)
await ds.invoke_startup()
ds.close()
with pytest.raises(DatasetteClosedError):
await ds.get_internal_database().execute("select 1")
@pytest.mark.asyncio
async def test_datasette_close_continues_past_db_error():
# If one Database raises during close(), the others still get closed.
ds = Datasette(memory=True)
await ds.invoke_startup()
class Boom(Database):
def close(self):
raise RuntimeError("boom")
bad = ds.add_database(Boom(ds, is_memory=True), name="bad")
good = ds.add_database(Database(ds, is_memory=True), name="good")
with pytest.raises(RuntimeError, match="boom"):
ds.close()
assert good._closed
assert ds._internal_database._closed