From ac2a9a43a530412f073d6f9a9046df86d9b161ff Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 8 Sep 2026 21:14:32 -0700 Subject: [PATCH] Add a default execution time limit to writes --- datasette/database.py | 14 +++++++++++++- datasette/views/execute_write.py | 3 ++- docs/internals.rst | 8 ++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/datasette/database.py b/datasette/database.py index 25523f3e..d444cbbf 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -247,17 +247,29 @@ class Database: return_all=False, returning_limit=EXECUTE_WRITE_RETURNING_LIMIT, transaction=True, + time_limit_ms=2000, ): self._check_not_closed() if returning_limit < 0: raise ValueError("returning_limit must be >= 0") - def _inner(conn): + def execute_sql(conn): cursor = conn.execute(sql, params or []) return ExecuteWriteResult.from_cursor( cursor, return_all=return_all, returning_limit=returning_limit ) + def _inner(conn): + try: + if time_limit_ms is None: + return execute_sql(conn) + with sqlite_timelimit(conn, time_limit_ms): + return execute_sql(conn) + except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: + if e.args == ("interrupted",): + raise QueryInterrupted(e, sql, params) + raise + with trace("sql", database=self.name, sql=sql.strip(), params=params): results = await self.execute_write_fn( _inner, block=block, request=request, transaction=transaction diff --git a/datasette/views/execute_write.py b/datasette/views/execute_write.py index 3dfb810c..c4f5e3fe 100644 --- a/datasette/views/execute_write.py +++ b/datasette/views/execute_write.py @@ -1,6 +1,7 @@ import re from urllib.parse import urlencode +from datasette.database import QueryInterrupted from datasette.resources import DatabaseResource from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3 from datasette.utils.asgi import Response @@ -384,7 +385,7 @@ class ExecuteWriteView(BaseView): try: execute_write_kwargs = {"request": request} cursor = await db.execute_write(sql, params, **execute_write_kwargs) - except sqlite3.DatabaseError as ex: + except (QueryInterrupted, sqlite3.DatabaseError) as ex: message = str(ex) if wants_json: return _block_framing(Response.error([message], 400)) diff --git a/docs/internals.rst b/docs/internals.rst index d2bd46ef..0039fd12 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2023,8 +2023,8 @@ Example usage: .. _database_execute_write: -await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True) --------------------------------------------------------------------------------------------------------------------------- +await db.execute_write(sql, params=None, block=True, request=None, return_all=False, returning_limit=10, transaction=True, time_limit_ms=2000) +---------------------------------------------------------------------------------------------------------------------------------------------- SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received. @@ -2063,6 +2063,10 @@ Each call to ``execute_write()`` will be executed inside a transaction. Pass ``transaction=False`` for statements such as ``VACUUM`` that cannot run inside a transaction. +Write statements have a default time limit of 2,000ms. Pass a different value +using ``time_limit_ms=`` or use ``time_limit_ms=None`` to allow the statement to +run without a time limit. + .. _database_execute_write_script: await db.execute_write_script(sql, block=True)