diff --git a/datasette/app.py b/datasette/app.py index 0f417ec9..367f38f9 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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) diff --git a/docs/internals.rst b/docs/internals.rst index 53c20106..2710345b 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -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) diff --git a/tests/test_internals_datasette.py b/tests/test_internals_datasette.py index ec0180a7..5f773658 100644 --- a/tests/test_internals_datasette.py +++ b/tests/test_internals_datasette.py @@ -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